Compare commits
56
Commits
849da8d471
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c34cfa76a | ||
|
|
9d0cdd7f52 | ||
|
|
9583612fdc | ||
|
|
0bc77b7463 | ||
|
|
4ff963d1e2 | ||
|
|
f9d2ff084d | ||
|
|
7aefe88610 | ||
|
|
09eea86183 | ||
|
|
ddfaa4b440 | ||
|
|
d3b9b0611b | ||
|
|
fe0c854f1d | ||
|
|
0b736b51fb | ||
|
|
af97468b2d | ||
|
|
a0170f5952 | ||
|
|
2f4f9800dd | ||
|
|
9fedc5b5d3 | ||
|
|
a35f91ac63 | ||
|
|
890e04f8a6 | ||
|
|
5e4e574076 | ||
|
|
2a041d25a0 | ||
|
|
df6734c939 | ||
|
|
8070fda64d | ||
|
|
d06b647dac | ||
|
|
a84275b40e | ||
|
|
2ec2a4c93f | ||
|
|
de4aedd26e | ||
|
|
5090f72fab | ||
|
|
c00dd86eb4 | ||
|
|
300bb2058f | ||
|
|
3fe510e64e | ||
|
|
48691227cb | ||
|
|
34ff630b75 | ||
|
|
3f806669c6 | ||
|
|
e31f607f66 | ||
|
|
8c53676133 | ||
|
|
2de985b009 | ||
|
|
1cda9d378a | ||
|
|
9fcba26942 | ||
|
|
13e5b6c3af | ||
|
|
493a43d952 | ||
|
|
7493afde45 | ||
|
|
d159cb212c | ||
|
|
c7e0ad67e6 | ||
|
|
f0ad5374e9 | ||
|
|
96d49fc488 | ||
|
|
2a554bb7bf | ||
|
|
c16e4bf53f | ||
|
|
18fb3382c6 | ||
|
|
0e2021e96d | ||
|
|
2fc9806c69 | ||
|
|
3116d23199 | ||
|
|
4aa2d7a5e6 | ||
|
|
7e182e3d51 | ||
|
|
c04d9e0faf | ||
|
|
3188f5f3da | ||
|
|
57781ba347 |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Tailwind 4's floor: browsers without cascade layers, or Chrome before 111, can't render the
|
||||
# app's CSS, so the JS isn't transpiled for them either.
|
||||
defaults and fully supports css-cascade-layers
|
||||
not chrome < 111
|
||||
+1
-1
@@ -17,7 +17,7 @@ use_dns_when_possible: true
|
||||
timezone: Europe/Berlin
|
||||
composer_version: "2"
|
||||
web_environment: []
|
||||
nodejs_version: "18"
|
||||
nodejs_version: "24"
|
||||
corepack_enable: false
|
||||
disable_upload_dirs_warning: true
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo
|
||||
MYEP_OAUTH2_SCOPES=email,id,roles,profile
|
||||
# Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage.
|
||||
APPLICATION_FORM_API_KEY=
|
||||
# Shared key n8n reads the assignments feed with, in the X-Api-Key header. Empty here: set it per stage.
|
||||
N8N_API_KEY=
|
||||
# Booking link for the Team-Infocall, substituted for {bookingUrl} in the application form
|
||||
# receipt mail. Empty here: set it per stage. While it is empty that mail is not sent at all -
|
||||
# an invitation with a dead booking link is worse than none.
|
||||
|
||||
@@ -10,5 +10,7 @@ APP_TRUSTED_HOSTS='^localhost$'
|
||||
# loaded in the test environment - so the throwaway key the tests send belongs here.
|
||||
# Keep it in sync with ApplicationFormControllerTest::KEY.
|
||||
APPLICATION_FORM_API_KEY=test-application-form-api-key
|
||||
# Keep in sync with Api\Assignment\ListControllerTest::KEY.
|
||||
N8N_API_KEY=test-n8n-api-key
|
||||
# The receipt mail refuses to render without it, and several tests send that mail.
|
||||
APPLICATION_FORM_BOOKING_URL=https://bookings.example.com/team-infocall
|
||||
@@ -3,6 +3,15 @@ htmx.config.includeIndicatorStyles = false
|
||||
htmx.config.historyCacheSize = 0
|
||||
htmx.config.allowScriptTags = false
|
||||
|
||||
// A boosted link is a page navigation: show the error page like the browser would, instead of
|
||||
// swallowing the 4xx/5xx and leaving the click without effect.
|
||||
document.addEventListener('htmx:beforeSwap', (event) => {
|
||||
if (true === event.detail.boosted && event.detail.xhr.status >= 400) {
|
||||
event.detail.shouldSwap = true
|
||||
event.detail.isError = false
|
||||
}
|
||||
})
|
||||
|
||||
import './bootstrap.js';
|
||||
/*
|
||||
* Welcome to your app's main JavaScript file!
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/*
|
||||
* Shows a questionnaire question only while its show_if holds, mirroring
|
||||
* App\Model\Questionnaire\Definition::isVisible(): the condition must be met and the question it
|
||||
* depends on must itself be shown.
|
||||
*
|
||||
* Every question row carries data-question-key; a conditional one also data-show-if, the JSON
|
||||
* {question, operator, value} that App\Form\QuestionnaireType writes. A hidden row stays in the
|
||||
* form and is submitted: a draft keeps its answer, and the server drops it on submit.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
|
||||
connect() {
|
||||
this.update()
|
||||
}
|
||||
|
||||
update() {
|
||||
const visible = {}
|
||||
|
||||
// Rows come in definition order, and a condition may only name an earlier question, so
|
||||
// the question a row depends on has always been decided by the time the row is reached.
|
||||
for (const row of this.element.querySelectorAll('[data-question-key]')) {
|
||||
const key = row.dataset.questionKey
|
||||
const condition = row.dataset.showIf ? JSON.parse(row.dataset.showIf) : null
|
||||
|
||||
visible[key] = null === condition
|
||||
|| (true === visible[condition.question] && this.isMet(condition))
|
||||
|
||||
row.hidden = false === visible[key]
|
||||
}
|
||||
}
|
||||
|
||||
isMet({ question, operator, value }) {
|
||||
const answer = this.answer(question)
|
||||
|
||||
if (null === answer) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case 'equals':
|
||||
return answer === value
|
||||
case 'in':
|
||||
return value.includes(answer)
|
||||
case 'lte':
|
||||
return Number(answer) <= value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
answer(key) {
|
||||
const row = this.element.querySelector(`[data-question-key="${key}"]`)
|
||||
|
||||
if (null === row) {
|
||||
return null
|
||||
}
|
||||
|
||||
const checked = row.querySelector('input[type="radio"]:checked')
|
||||
|
||||
if (null !== checked) {
|
||||
return checked.value
|
||||
}
|
||||
|
||||
const select = row.querySelector('select')
|
||||
|
||||
if (null !== select) {
|
||||
return '' === select.value ? null : select.value
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
@import "components/badge.css";
|
||||
@import "components/autocomplete.css";
|
||||
@import "components/button.css";
|
||||
@import "components/datatable.css";
|
||||
@import "components/datepicker.css";
|
||||
@import "components/toast.css";
|
||||
@import "components/tooltip.css";
|
||||
@import "components/timeline.css";
|
||||
@import "components/mail-body.css";
|
||||
@import "./components/badge.css";
|
||||
@import "./components/autocomplete.css";
|
||||
@import "./components/button.css";
|
||||
@import "./components/datatable.css";
|
||||
@import "./components/datepicker.css";
|
||||
@import "./components/toast.css";
|
||||
@import "./components/tooltip.css";
|
||||
@import "./components/timeline.css";
|
||||
@import "./components/mail-body.css";
|
||||
+44
-5
@@ -1,7 +1,46 @@
|
||||
@import "tailwindcss/base";
|
||||
@import "_base.css";
|
||||
@import "tailwindcss";
|
||||
@import "./_base.css" layer(base);
|
||||
@import "./_components.css" layer(components);
|
||||
|
||||
@import "tailwindcss/components";
|
||||
@import "_components.css";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@import "tailwindcss/utilities";
|
||||
@source inline("rotate-90");
|
||||
|
||||
@theme {
|
||||
--font-sans: Lato, sans-serif;
|
||||
--font-weight-semibold: 700;
|
||||
--font-weight-bold: 900;
|
||||
--color-primary: #3d8ccb;
|
||||
--color-secondary: #f9c700;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tailwind 3 defaults that Tailwind 4 changed: borders were gray-200 instead of currentColor,
|
||||
* and buttons had a pointer cursor.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Thin scrollbars without a track for inner scrolling areas. The page scrollbar takes its
|
||||
* style from <html>, which body * leaves out. Firefox and Chrome/Edge 121+ honour these;
|
||||
* Safari keeps its own overlay scrollbars.
|
||||
*/
|
||||
@layer base {
|
||||
body * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-gray-300) transparent;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.selected:hover,
|
||||
.flatpickr-day.selected:focus {
|
||||
background-color: theme('colors.secondary');
|
||||
border-color: theme('colors.secondary');
|
||||
background-color: var(--color-secondary);
|
||||
border-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.flatpickr-monthDropdown-months,
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months {
|
||||
@apply form-select pt-0 pb-0 focus:outline-none focus:shadow-none;
|
||||
@apply form-select pt-0 pb-0 focus:outline-hidden focus:shadow-none;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
.scroll-container {
|
||||
@apply overflow-scroll;
|
||||
@apply overflow-auto;
|
||||
max-height: calc(100vh - 320px);
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
@import "toastify-js/src/toastify.css";
|
||||
|
||||
.toastify {
|
||||
@apply shadow-lg rounded;
|
||||
@apply shadow-lg rounded-sm;
|
||||
}
|
||||
|
||||
.toastify--success {
|
||||
@apply text-gray-100;
|
||||
background: theme('colors.emerald.600');
|
||||
background: var(--color-emerald-600);
|
||||
}
|
||||
|
||||
.toastify--warning {
|
||||
@apply text-gray-100;
|
||||
background: theme('colors.red.600');
|
||||
background: var(--color-red-600);
|
||||
}
|
||||
|
||||
.toastify--info {
|
||||
@apply text-gray-100;
|
||||
background: theme('colors.blue.600');
|
||||
background: var(--color-blue-600);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
@import "tippy.js/dist/tippy.css";
|
||||
|
||||
.tippy-box {
|
||||
background-color: theme('colors.primary');
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: theme('colors.primary');
|
||||
border-top-color: var(--color-primary);
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: theme('colors.primary');
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: theme('colors.primary');
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: theme('colors.primary');
|
||||
border-right-color: var(--color-primary);
|
||||
}
|
||||
Generated
+708
-598
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ security:
|
||||
memory:
|
||||
users:
|
||||
api: { roles: [ ROLE_API ] }
|
||||
n8n: { roles: [ ROLE_API_ASSIGNMENTS ] }
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Enabled for #[MapRequestPayload] on the api controllers - nothing else in this application
|
||||
# serializes. ALLOW_EXTRA_ATTRIBUTES stays at its default (true) on purpose: the TYPO3 form
|
||||
# payload grows keys without a release here, and rejecting an unknown one would turn every
|
||||
# later mapping change into a 400 (see .ddev/plans/application-form-endpoint.md).
|
||||
# later mapping change into a 400.
|
||||
framework:
|
||||
serializer:
|
||||
name_converter: ~
|
||||
@@ -0,0 +1,271 @@
|
||||
# Questionnaire a teamer fills in after a placement with bus escort, in addition to
|
||||
# teamer_feedback.yaml and on the same page (App\Service\Questionnaire\DispositionContext).
|
||||
#
|
||||
# Replaces the Google form "Bus-Feedback 25-26"; the question-by-question spec, with every decision
|
||||
# taken, is kept locally outside the repository. Questions whose answer the
|
||||
# disposition already knows (travel date, destination, Reiseleitung, first pickup) are not asked:
|
||||
# they are snapshotted into the response's context instead.
|
||||
#
|
||||
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
|
||||
# removing a question or option key, or changing a type, needs a new version: copy this file to
|
||||
# bus_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
|
||||
#
|
||||
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
|
||||
#
|
||||
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
|
||||
# required (default false), help, widget (choice only: radio | dropdown, default radio),
|
||||
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
|
||||
|
||||
key: bus_feedback
|
||||
version: 1
|
||||
title: "Bus-Feedback"
|
||||
|
||||
sections:
|
||||
- key: bus_trip
|
||||
title: "Busfahrt"
|
||||
questions:
|
||||
- key: direction
|
||||
label: "Hin- oder Rückfahrt"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: outbound, label: "Hinfahrt" }
|
||||
- { key: return, label: "Rückfahrt" }
|
||||
|
||||
- key: bus_company
|
||||
label: "Bus-Unternehmen:"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: boonk, label: "Boonk Reisen" }
|
||||
- { key: piccolonia, label: "Piccolonia" }
|
||||
- { key: heess, label: "Heeß" }
|
||||
- { key: finkbeiner, label: "Finkbeiner" }
|
||||
- { key: other, label: "Anderes Unternehmen" }
|
||||
|
||||
- key: bus_company_other
|
||||
label: "Name des Busunternehmens:"
|
||||
type: text
|
||||
required: true
|
||||
show_if: { question: bus_company, equals: other }
|
||||
|
||||
- key: bus_team
|
||||
title: "Busteam"
|
||||
questions:
|
||||
- key: driver_names
|
||||
label: "Busfahrer-Namen:"
|
||||
type: text
|
||||
required: true
|
||||
|
||||
- key: bus_first_pickup
|
||||
title: "1. Abfahrtsort"
|
||||
questions:
|
||||
- key: on_time_at_first_pickup
|
||||
label: "Bus pünktlich am 1. Abfahrtsort?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: delay_at_first_pickup
|
||||
label: "Wieviel Verspätung hatte der Bus am 1. Abfahrtsort?"
|
||||
type: text
|
||||
required: true
|
||||
show_if: { question: on_time_at_first_pickup, equals: "no" }
|
||||
|
||||
- key: bus_rating
|
||||
title: "Bewertung Bus & Fahrer"
|
||||
questions:
|
||||
- key: overall_impression
|
||||
label: "Gesamteindruck des Busses"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: bad, label: "schlecht" }
|
||||
|
||||
- key: vehicle_age
|
||||
label: "Alter des Fahrzeugs"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_new, label: "Sehr neu" }
|
||||
- { key: new, label: "neu" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: somewhat_old, label: "etwas älter" }
|
||||
- { key: very_old, label: "sehr alt" }
|
||||
|
||||
- key: seating_comfort
|
||||
label: "Sitzkomfort im Bus"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: bad, label: "schlecht" }
|
||||
|
||||
- key: driver_friendliness
|
||||
label: "Freundlichkeit der Fahrer"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_friendly, label: "Sehr freundlich" }
|
||||
- { key: friendly, label: "freundlich" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_friendly, label: "nicht so freundlich" }
|
||||
- { key: unfriendly, label: "unfreundlich" }
|
||||
|
||||
- key: breaks
|
||||
label: "Pausenzeiten"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: unacceptable, label: "geht gar nicht" }
|
||||
|
||||
- key: temperature
|
||||
label: "Temperatur im Bus"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: unacceptable, label: "geht gar nicht" }
|
||||
|
||||
- key: guest_behaviour
|
||||
label: "Verhalten der Gäste im Bus"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: unacceptable, label: "geht gar nicht" }
|
||||
|
||||
- key: bus_pickups
|
||||
title: "Ladestellen"
|
||||
questions:
|
||||
- key: drivers_knew_pickups
|
||||
label: "Waren den Fahrern alle Abfahrtsorte bekannt?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: unknown_pickups
|
||||
label: "Welche Abfahrtsorte waren nicht bekannt? Gab es dadurch Probleme?"
|
||||
type: textarea
|
||||
required: true
|
||||
show_if: { question: drivers_knew_pickups, equals: "no" }
|
||||
|
||||
- key: bus_destination
|
||||
title: "Zielkenntnis"
|
||||
questions:
|
||||
- key: drivers_knew_destination
|
||||
label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: bus_equipment
|
||||
title: "Bus, Technik & Allgemeines"
|
||||
questions:
|
||||
- key: video
|
||||
label: "Video/DVD-Gerät"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: working, label: "funktionstüchtig" }
|
||||
- { key: broken, label: "defekt" }
|
||||
- { key: missing, label: "nicht vorhanden" }
|
||||
|
||||
- key: wc
|
||||
label: "WC"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: working, label: "funktionstüchtig" }
|
||||
- { key: broken, label: "defekt" }
|
||||
- { key: closed, label: "nicht geöffnet" }
|
||||
|
||||
- key: luggage
|
||||
label: "Gepäck"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: trailer, label: "Anhänger" }
|
||||
- { key: ski_box, label: "Skikoffer" }
|
||||
- { key: trailer_and_ski_box, label: "Anhänger und Skikoffer" }
|
||||
- { key: none, label: "Kein Anhänger oder Skikoffer" }
|
||||
|
||||
- key: smoking
|
||||
label: "Rauchen im Bus"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: driver, label: "Busfahrer hat im Bus geraucht" }
|
||||
- { key: driver_and_guests, label: "Busfahrer und Gäste haben im Bus geraucht" }
|
||||
- { key: nobody, label: "Es wurde nicht geraucht" }
|
||||
|
||||
- key: snow_chains
|
||||
label: "Schneeketten"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: fitted, label: "Fahrer hat Ketten aufgezogen" }
|
||||
- { key: not_needed, label: "Ketten wurden nicht gebraucht" }
|
||||
- { key: not_fitted, label: "Ketten nicht aufgezogen (trotz starkem Schneefall)" }
|
||||
|
||||
- key: bus_damage
|
||||
title: "Schäden"
|
||||
questions:
|
||||
- key: bus_damage
|
||||
label: "Entstanden während der Fahrt Schäden am Bus?"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: none, label: "Keine Schäden" }
|
||||
- { key: settled_with_driver, label: "Bereits mit Fahrer abgeklärt" }
|
||||
- { key: claim_expected, label: "Da wird vom Busunternehmen noch was kommen" }
|
||||
|
||||
- key: damage_description
|
||||
label: "Was ist passiert?"
|
||||
type: textarea
|
||||
required: true
|
||||
show_if: { question: bus_damage, equals: claim_expected }
|
||||
|
||||
- key: bus_remarks
|
||||
title: "Anmerkungen zur Busfahrt"
|
||||
questions:
|
||||
- key: bus_remarks
|
||||
label: "Das ging gar nicht / ist besonders hervorzuheben:"
|
||||
type: textarea
|
||||
@@ -0,0 +1,137 @@
|
||||
# Questionnaire every teamer fills in after a placement (Disposition, not SeasonAssignment).
|
||||
#
|
||||
# Replaces the Google form "RL-Feedback 25-26"; the question-by-question spec, with every decision
|
||||
# taken, is kept locally outside the repository. A teamer who escorted the bus
|
||||
# also gets bus_feedback.yaml, on the same page. Questions whose answer the disposition already
|
||||
# knows (name, job profile, house, arrival day) are not asked: they are snapshotted into the
|
||||
# response's context instead.
|
||||
#
|
||||
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
|
||||
# removing a question or option key, or changing a type, needs a new version: copy this file to
|
||||
# teamer_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
|
||||
#
|
||||
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
|
||||
#
|
||||
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
|
||||
# required (default false), help, widget (choice only: radio | dropdown, default radio),
|
||||
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
|
||||
|
||||
key: teamer_feedback
|
||||
version: 1
|
||||
title: "Wochenteamer:innen-Feedback"
|
||||
description: |-
|
||||
Nur durch euer Feedback können wir besser werden! 🙂
|
||||
|
||||
Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut
|
||||
|
||||
sections:
|
||||
- key: organisation
|
||||
title: "Organisation & Ablauf"
|
||||
questions:
|
||||
- key: office_briefing_rating
|
||||
label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?"
|
||||
type: rating
|
||||
required: true
|
||||
|
||||
- key: office_briefing_low_reason
|
||||
label: "Wenn 1 - 3 Sterne Bewertung, warum?"
|
||||
type: textarea
|
||||
required: true
|
||||
show_if: { question: office_briefing_rating, lte: 3 }
|
||||
|
||||
- key: team_week_seminar
|
||||
label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." }
|
||||
- { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." }
|
||||
- { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." }
|
||||
- { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." }
|
||||
- { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." }
|
||||
- { key: not_attended, label: "Ich habe nicht teilgenommen." }
|
||||
|
||||
- key: onsite_organisation_rating
|
||||
label: "Wie gut lief die Reiseorganisation vor Ort?"
|
||||
type: rating
|
||||
required: true
|
||||
|
||||
- key: had_organisational_problems
|
||||
label: "Gab es organisatorische Probleme?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: organisational_problems
|
||||
label: "Wenn ja, welche?"
|
||||
type: text
|
||||
show_if: { question: had_organisational_problems, equals: "yes" }
|
||||
|
||||
- key: communication
|
||||
title: "Kommunikation & Zusammenarbeit"
|
||||
questions:
|
||||
- key: teamer_collaboration_rating
|
||||
label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?"
|
||||
type: rating
|
||||
required: true
|
||||
|
||||
- key: support_wished
|
||||
label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?"
|
||||
type: textarea
|
||||
|
||||
- key: house_manager_problem_handling
|
||||
label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." }
|
||||
- { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." }
|
||||
- { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." }
|
||||
- { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." }
|
||||
- { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." }
|
||||
|
||||
- key: felt_overwhelmed
|
||||
label: "Gab es Momente, in denen du dich überfordert gefühlt hast?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: overwhelmed_reason
|
||||
label: "Wenn ja, wodurch?"
|
||||
type: textarea
|
||||
show_if: { question: felt_overwhelmed, equals: "yes" }
|
||||
|
||||
- key: improvement
|
||||
title: "Verbesserung"
|
||||
questions:
|
||||
- key: wished_differently
|
||||
label: "Was hättest du dir anders oder besser gewünscht?"
|
||||
type: textarea
|
||||
|
||||
- key: would_work_again
|
||||
label: "Würdest du wieder als Reiseleitung für E&P arbeiten?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: would_not_work_again_reason
|
||||
label: "Wenn nein, warum nicht?"
|
||||
type: textarea
|
||||
show_if: { question: would_work_again, equals: "no" }
|
||||
|
||||
- key: other
|
||||
title: "Sonstiges"
|
||||
questions:
|
||||
- key: anything_else
|
||||
label: |-
|
||||
Du hast nun schon vieles beantwortet,
|
||||
aber nirgendwo konntest du folgende Aspekte loswerden:
|
||||
type: textarea
|
||||
+68
-1
@@ -40,6 +40,37 @@ parameters:
|
||||
# rejects every request instead of letting them through.
|
||||
application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%'
|
||||
|
||||
# Shared key n8n reads the assignments feed with (GET /api/assignments). Same rules as above.
|
||||
n8n_api_key: '%env(string:N8N_API_KEY)%'
|
||||
|
||||
# What the n8n assignments feed covers: assignments in these houses (matched against the first
|
||||
# three characters of destination.hotelCode) for these job profiles - guide, course and
|
||||
# service-team work. Job profile ids are database ids, so check them against production.
|
||||
assignment_export.house_codes: [ DPW, DGS, DWW, DKI, DKS ]
|
||||
assignment_export.job_profile_ids:
|
||||
- 5 # Reiseleitung
|
||||
- 9 # Reiseleitung Ski
|
||||
- 10 # Reiseleitung Snowboard
|
||||
- 3 # Kursleitung Ski Anfänger:innen
|
||||
- 14 # Kursleitung Ski Fortgeschrittene
|
||||
- 18 # Kursleitung Ski Anfänger:innen + Fortgeschrittene
|
||||
- 36 # Kursleitung Ski Kinder Anfänger:innen & Kinderbetreuung
|
||||
- 37 # Kursleitung Ski Kinder Fortgeschrittene
|
||||
- 38 # Kursleitung Ski Kinder
|
||||
- 4 # Kursleitung Snowboard Anfänger:innen
|
||||
- 15 # Kursleitung Snowboard Fortgeschrittene
|
||||
- 19 # Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
|
||||
- 47 # Kursleitung Snowboard Kinder/Jugendliche Anfänger:innen
|
||||
- 20 # Reise- und Kursleitung Ski Anfänger:innen
|
||||
- 21 # Reise- und Kursleitung Ski Fortgeschrittene
|
||||
- 22 # Reise- und Kursleitung Ski Anfänger:innen + Fortgeschrittene
|
||||
- 23 # Reise- und Kursleitung Snowboard Anfänger:innen
|
||||
- 24 # Reise- und Kursleitung Snowboard Fortgeschrittene
|
||||
- 25 # Reise- und Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
|
||||
- 6 # Serviceteam 40h (ST40)
|
||||
- 13 # Serviceteam 25h (ST25)
|
||||
- 16 # Serviceteam 10h (ST10)
|
||||
|
||||
# Booking link the application form receipt mail invites the applicant to. A deployment
|
||||
# value rather than part of the wording, so a new booking system does not need an edit in
|
||||
# every stage's mail text.
|
||||
@@ -90,12 +121,31 @@ parameters:
|
||||
season_start: '11-01'
|
||||
season_end: '04-01'
|
||||
|
||||
# The teamer questionnaires after a placement (config/questionnaires/teamer_feedback.yaml, plus
|
||||
# bus_feedback.yaml for a bus escort) are offered for placements that ended within this many
|
||||
# days. Without a window every past placement would show up as pending on the teamers' dashboards.
|
||||
questionnaire_window_days: 30
|
||||
|
||||
# How far ahead of the destination's first travel day a season assignment's checklist
|
||||
# entries fall due. A relative date expression, applied to destination.dateFrom, and the
|
||||
# single definition of both deadlines: the first is this offset, the second defaults to
|
||||
# the first. Per-entry overrides are stored on the task row; this is what they override.
|
||||
season_task_deadline_offset: '-1 month'
|
||||
|
||||
# What the staffing matrix generates (App\Service\Assignment\AssignmentPlanner), keyed by
|
||||
# App\Enum\StaffingRole / StaffingFee value. Ids and not names, so renaming a job profile
|
||||
# or fee in the admin cannot silently change what the drafts get. A missing or deleted id
|
||||
# makes App\Service\Assignment\StaffingResolver refuse rather than skip.
|
||||
auto_assignment_job_profiles:
|
||||
guide: 5 # Reiseleitung
|
||||
ski_beginner: 3 # Kursleitung Ski Anfänger:innen
|
||||
ski_advanced: 14 # Kursleitung Ski Fortgeschrittene
|
||||
guide_ski_beginner: 20 # Reise- und Kursleitung Ski Anfänger:innen
|
||||
guide_ski_advanced: 21 # Reise- und Kursleitung Ski Fortgeschrittene
|
||||
auto_assignment_fees:
|
||||
guiding: 3 # Skigebietsguiding (pro Einheit)
|
||||
course: 4 # Ski-/Snowboardkurs (pro Stunde)
|
||||
|
||||
# Houses, hotel code: name. The name is used both as a label and to match
|
||||
# destination.hotel, the code to match destination.hotelCode.
|
||||
houses:
|
||||
@@ -137,10 +187,15 @@ services:
|
||||
# Only the path enters the container - the file itself is parsed on demand by
|
||||
# App\Config\EmailTextCatalog, since its contents matter on few requests.
|
||||
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
|
||||
# Likewise parsed on demand, by App\Config\QuestionnaireCatalog.
|
||||
$questionnaireDir: '%kernel.project_dir%/config/questionnaires'
|
||||
$questionnaireWindowDays: '%questionnaire_window_days%'
|
||||
$mailingBusTransport: '%mailing_bus_transport%'
|
||||
$seasonStart: '%season_start%'
|
||||
$seasonEnd: '%season_end%'
|
||||
$seasonTaskDeadlineOffset: '%season_task_deadline_offset%'
|
||||
$autoAssignmentJobProfiles: '%auto_assignment_job_profiles%'
|
||||
$autoAssignmentFees: '%auto_assignment_fees%'
|
||||
|
||||
# Tagged by interface rather than listed by hand: order between collectors is
|
||||
# irrelevant, so the only thing an explicit list could add here is the chance to
|
||||
@@ -412,7 +467,10 @@ services:
|
||||
|
||||
App\Security\AccessToken\ApiKeyTokenHandler:
|
||||
arguments:
|
||||
$apiKey: '%application_form_api_key%'
|
||||
# user identifier in security.yaml's api_user_provider => key
|
||||
$apiKeys:
|
||||
api: '%application_form_api_key%'
|
||||
n8n: '%n8n_api_key%'
|
||||
$logger: '@monolog.logger.api'
|
||||
|
||||
App\Controller\Api\ApplicationFormController:
|
||||
@@ -420,6 +478,13 @@ services:
|
||||
$logger: '@monolog.logger.api'
|
||||
tags: [ 'controller.service_arguments' ]
|
||||
|
||||
App\Controller\Api\Assignment\ListController:
|
||||
arguments:
|
||||
$houseCodes: '%assignment_export.house_codes%'
|
||||
$jobProfileIds: '%assignment_export.job_profile_ids%'
|
||||
$logger: '@monolog.logger.api'
|
||||
tags: [ 'controller.service_arguments' ]
|
||||
|
||||
App\EventListener\EmailRecipientWhitelistSubscriber:
|
||||
arguments:
|
||||
$allowedRecipients: '%mail_recipient_whitelist%'
|
||||
@@ -447,4 +512,6 @@ when@dev:
|
||||
|
||||
when@test:
|
||||
parameters:
|
||||
# a job profile id ListControllerTest inserts itself - db_test has no job profiles
|
||||
assignment_export.job_profile_ids: [ 900001 ]
|
||||
mailing_bus_transport: 'sync'
|
||||
+2
-1
@@ -38,6 +38,7 @@ $rsyncOptions = [
|
||||
'.env.test.local',
|
||||
'.gitignore',
|
||||
'.nvmrc',
|
||||
'.browserslistrc',
|
||||
'.php-cs-fixer.cache',
|
||||
'.php-cs-fixer.dist.php',
|
||||
'.phpunit.result.cache',
|
||||
@@ -51,7 +52,7 @@ $rsyncOptions = [
|
||||
'phpunit.xml.dist',
|
||||
'public/.htaccess',
|
||||
'tailwind.config.js',
|
||||
'webpack.config.js',
|
||||
'webpack.config.mjs',
|
||||
'rapidapi.paw',
|
||||
'bin/.phpunit',
|
||||
'temp/*',
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
# API Consumer Guide
|
||||
|
||||
This document describes the public JSON API exposed under `/api`, written for developers implementing a **client** in another application. It is self-contained: no access to this codebase is required to use it.
|
||||
|
||||
## Overview
|
||||
|
||||
A Symfony application exposing a read-mostly JSON API under the `/api` prefix. All `/api/*` requests are stateless and authenticated via OAuth2 Bearer tokens (League OAuth2 Server, JWT access tokens). Responses are `application/json`. HTTPS is enforced on all routes.
|
||||
|
||||
## Authentication
|
||||
|
||||
- Token endpoint: `POST /token` (unauthenticated).
|
||||
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
||||
- Access token TTL: **10 minutes** — clients must refresh/re-request frequently.
|
||||
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
||||
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||||
- Send the token as `Authorization: Bearer <access_token>`.
|
||||
|
||||
Scope required per endpoint:
|
||||
|
||||
| Scope needed | Endpoints |
|
||||
|---|---|
|
||||
| `api` | everything except the two below |
|
||||
| `profile` | `GET /api/crm-attributes` |
|
||||
| `email` | `GET /api/userinfo` (optional extra scopes `id`, `profile`, `roles` widen the response) |
|
||||
|
||||
`/api/userinfo` and `/api/crm-attributes` act on the **authenticated end user**, so they require an authorization_code token issued for a user. All other endpoints are machine-to-machine and work with client_credentials.
|
||||
|
||||
### Machine-to-machine example
|
||||
|
||||
```
|
||||
POST /token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=client_credentials&client_id=...&client_secret=...&scope=api
|
||||
```
|
||||
|
||||
```
|
||||
GET /api/hotels
|
||||
Authorization: Bearer eyJ0eXAi...
|
||||
```
|
||||
|
||||
## Error conventions
|
||||
|
||||
There is no single error envelope; three shapes occur:
|
||||
|
||||
- `{"message": "..."}` — commonly with 404 / 400.
|
||||
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
|
||||
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
|
||||
|
||||
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
||||
|
||||
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Reference data (BusProNet master data)
|
||||
|
||||
#### `GET /api/hotels` — scope `api`
|
||||
|
||||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`.
|
||||
|
||||
#### `GET /api/hotels/{id}` — scope `api`
|
||||
|
||||
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown.
|
||||
|
||||
#### `GET /api/countries` — scope `api`
|
||||
|
||||
Array of `{id, name, token, nationality}`.
|
||||
|
||||
#### `GET /api/products` — scope `api`
|
||||
|
||||
Product master data from the upstream BusProNet system, cached server-side for 1 hour. On upstream failure returns `{"error": "...", "type": "api_error"}` **with status 200**.
|
||||
|
||||
#### `GET /api/last-update` — scope `api`
|
||||
|
||||
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown".
|
||||
|
||||
---
|
||||
|
||||
### Pickups (bus boarding points)
|
||||
|
||||
#### `GET /api/pickups` — scope `api`
|
||||
|
||||
Array of `{id, code, city, postalCode, street}`.
|
||||
|
||||
#### `GET /api/pickups/{id}` — scope `api`
|
||||
|
||||
Single pickup by numeric id, same fields as the list.
|
||||
|
||||
#### `GET /api/pickups-planning/{travelCode}` — scope `api`
|
||||
|
||||
Planned outbound (HIN) boarding stops for one travel code. Returns an array, chronologically sorted:
|
||||
|
||||
```json
|
||||
[{"city": "...", "location": "...", "datetime": "YYYY-MM-DD HH:MM", "busNumber": "..."}]
|
||||
```
|
||||
|
||||
`404 {"error":"Planning data not available"}` if no planning has been uploaded yet, `404 {"error":"Travel code not found"}` for an unknown code, `500 {"error":"Failed to read planning data"}` on storage errors.
|
||||
|
||||
#### `POST /api/pickups-planning` — scope `api`
|
||||
|
||||
Bulk-replaces the planning dataset. Body: a JSON **array** of raw planning rows using the upstream German keys:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PlanungsArt": "HIN",
|
||||
"Reisecode": "ABC123",
|
||||
"Zustieg/Ausstieg Ort": "Köln",
|
||||
"Zustieg/Ausstieg Strasse": "Hauptbahnhof",
|
||||
"Zeit": "24.12.2026 07:30",
|
||||
"BusNummer": "1"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Rows without `PlanungsArt === "HIN"` or without `Reisecode` are dropped; duplicates (same city + location + datetime + bus) are de-duplicated; results are grouped by `Reisecode` and sorted by time. Response `{"status":"ok"}` (200). `400 {"error":"Invalid JSON payload"}`, `500 {"error":"Failed to write pickups planning data"}`.
|
||||
|
||||
This is a full overwrite, not a merge.
|
||||
|
||||
---
|
||||
|
||||
### Travels
|
||||
|
||||
#### `GET /api/travels` — scope `api`
|
||||
|
||||
Index/mapping of all known travel dates. Object keyed by numeric travel id, each value `{id, code, label, dateFrom, dateTo, hotels: [...]}`.
|
||||
|
||||
#### `GET /api/travels/{dateId}/{hotelId}` — scope `api`
|
||||
|
||||
Single travel by **numeric** ids (`hotelId` optional). Route matches only when both segments are digits.
|
||||
|
||||
#### `GET /api/travels/{dateCode}/{hotelCode}` — scope `api`
|
||||
|
||||
Same, addressed by **string** codes (`hotelCode` optional). The date code is sanitized (separators stripped) before lookup. `404 {"message":"Not found"}` if the date code is unknown, `404 {"message":"Travel not found"}` if no data exists.
|
||||
|
||||
Both variants accept optional query parameters:
|
||||
|
||||
- `source=local` — force the local XML/snapshot data.
|
||||
- `source=remote` — force a live upstream call.
|
||||
- `prefer_remote=1` — used only when `source` is absent; prefers upstream with local fallback.
|
||||
|
||||
Response fields: `id`, `code`, `productCode`, `productId`, `label`, `hotelId`, `hotel` (hotel object), `dateFrom`, `dateTo`, `type`, `status`, `allowedBookingStatus[]`, `priceFrom`, `selectionGroups[]`, `additionalServices[]`, `transportationServices[]`, `pickups[]`, `dropOffs[]`, `rooms[]`, `guide`, `insurances[]`.
|
||||
|
||||
#### `GET /api/travels/{dateId}/{hotelId}/{dateTo}/availability` — scope `api`
|
||||
|
||||
Live hotel availability from the upstream system. `dateTo` must be `Y-m-d`. On upstream failure returns `{"error":"...","type":"api_error"}` **with status 200**.
|
||||
|
||||
---
|
||||
|
||||
### Contingents & accommodation prices
|
||||
|
||||
#### `GET /api/contingents/prices?hotelCode=…&year=…` — scope `api`
|
||||
|
||||
Price timeline for one accommodation and calendar year, collapsed into contiguous segments (adjacent days with the same effective price are merged into one row). The timeline never starts before today; if the requested year is entirely in the past the response is `[]`.
|
||||
|
||||
- `hotelCode`: required, max 16 chars, `[A-Za-z0-9_-]+` (matches the accommodation's calendar code).
|
||||
- `year`: required int, 1000–9999.
|
||||
|
||||
Each row:
|
||||
|
||||
```json
|
||||
{
|
||||
"dateFrom": "2026-06-01",
|
||||
"dateTo": "2026-06-14",
|
||||
"season": "high|low|...|null",
|
||||
"includedPax": 2,
|
||||
"pricePerNight": 89.5,
|
||||
"priceAdditionalPerson": 25.0,
|
||||
"defaultPricePerNight": 99.0,
|
||||
"defaultPriceAdditionalPerson": 30.0,
|
||||
"currency": "EUR",
|
||||
"type": "discount|null"
|
||||
}
|
||||
```
|
||||
|
||||
Prices are **decimal major units** (converted from integer minor units server-side). `default*` fields are non-null only when the winning price is a discount, and then carry the undiscounted price. `400 {"error":"Hotel not found for hotelCode."}` for an unknown code; `400` with violations for invalid parameters.
|
||||
|
||||
#### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
||||
|
||||
Per-day availability enriched with prices. Both dates are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); the range may not exceed 366 days and `dateTo` must not precede `dateFrom`. Upstream contingent data is cached 1 hour per (hotelCode, dateFrom, dateTo).
|
||||
|
||||
Each entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"status": "Free|Blocked|...",
|
||||
"type": "discount|null",
|
||||
"pricePerNight": 89.5,
|
||||
"defaultPricePerNight": 99.0,
|
||||
"priceAdditionalPerson": 25.0,
|
||||
"defaultPriceAdditionalPerson": 30.0,
|
||||
"currency": "EUR",
|
||||
"includedPax": 2,
|
||||
"minNights": 2
|
||||
}
|
||||
```
|
||||
|
||||
Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case.
|
||||
|
||||
Errors: `400 {"error":"Hotel not found for hotelCode."}`, `502 {"error":"Failed to fetch contingent data."}` when the upstream contingent service fails, `400` with violations for invalid parameters.
|
||||
|
||||
---
|
||||
|
||||
### Accommodation bookings (group bookings)
|
||||
|
||||
#### `GET /api/accommodation-bookings/{uuid}` — scope `api`
|
||||
|
||||
Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "...",
|
||||
"status": "...",
|
||||
"type": "...",
|
||||
"dateFrom": "2026-06-01",
|
||||
"dateTo": "2026-06-08",
|
||||
"nights": 7,
|
||||
"paxCount": 40,
|
||||
"minorsCount": 12,
|
||||
"childrenCount": 3,
|
||||
"groupName": "...",
|
||||
"acceptedAt": "2026-05-01T10:00:00+00:00",
|
||||
"personalData": {},
|
||||
"accommodation": {},
|
||||
"boardService": {},
|
||||
"additionalServices": [],
|
||||
"accommodationDiscount": null,
|
||||
"boardServiceDiscount": null,
|
||||
"additionalServicesDiscount": null,
|
||||
"totalPrice": 123456,
|
||||
"pricingCurrency": "EUR",
|
||||
"pricingVersion": 3,
|
||||
"priceBreakdown": {}
|
||||
}
|
||||
```
|
||||
|
||||
`totalPrice` and the `*Discount` fields are integer **minor units** (cents) — unlike the contingent endpoints. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||||
|
||||
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
|
||||
|
||||
Marks the booking as accepted (server-side side effects: status change, `acceptedAt`, notifications). No request body. Returns the same booking representation as the GET. `404` if unknown. Not idempotent in terms of side effects — call once.
|
||||
|
||||
---
|
||||
|
||||
### Contact form
|
||||
|
||||
#### `POST /api/contactform` — scope `api`
|
||||
|
||||
Creates or matches an address record in the upstream CRM.
|
||||
|
||||
Request body (all fields optional strings; the upstream system decides what is required):
|
||||
|
||||
```json
|
||||
{
|
||||
"lastName": "...", "firstName": "...", "gender": "...",
|
||||
"street": "...", "zipCode": "...", "city": "...",
|
||||
"email": "...", "phone": "..."
|
||||
}
|
||||
```
|
||||
|
||||
- `201` — `{"success": true, "addressId": 123, "personId": 456, "isNewRecord": true}`
|
||||
- `409` — `{"success": false, "message": "<upstream notification>"}` (upstream rejected/conflicted)
|
||||
- `400` — `{"success": false, "message": "<error>"}` (upstream call failed)
|
||||
- `422` — malformed payload
|
||||
|
||||
---
|
||||
|
||||
### Newsletter
|
||||
|
||||
#### `GET /api/newsletters` — scope `api`
|
||||
|
||||
The configured newsletter lists as an array of `{id, name, ...}` mapping objects. Use the returned `id` values as `listIds` below.
|
||||
|
||||
#### `POST /api/newsletter-subscriptions` — scope `api`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"firstName": "Ada",
|
||||
"lastName": "Lovelace",
|
||||
"listIds": [1, 2]
|
||||
}
|
||||
```
|
||||
|
||||
- `email`: required, strict email validation.
|
||||
- `firstName` / `lastName`: optional, max 255 chars.
|
||||
- `listIds`: required, at least one positive integer; every id must be one of the configured lists.
|
||||
|
||||
Responses:
|
||||
|
||||
- `200` — subscription completed, `state: "subscribed"`.
|
||||
- `202` — a double-opt-in confirmation mail was requested, `state: "confirmation_requested"`.
|
||||
- Body in both cases:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"email": "[email protected]",
|
||||
"lists": [{"id": 1, "name": "...", "state": "success|pending|already_registered"}],
|
||||
"state": "subscribed|pending_confirmation|confirmation_requested",
|
||||
"confirmationRequested": true
|
||||
}
|
||||
```
|
||||
|
||||
- `400` — unknown or disallowed list ids: `{"success": false, "message": "...", "listIds": [...], "unknownListIds": [...]}`, or validation violations.
|
||||
- `503` — `{"success": false, "email": "...", "listIds": [...], "message": "Newsletter subscription request could not be processed."}` when the newsletter provider is unavailable. Safe to retry.
|
||||
|
||||
---
|
||||
|
||||
### End-user endpoints (require a user-bound token)
|
||||
|
||||
#### `GET /api/userinfo` — scope `email` (+ optional `id`, `profile`, `roles`)
|
||||
|
||||
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
|
||||
|
||||
- always: `email`
|
||||
- scope `id`: `person_id`, `address_id`
|
||||
- scope `roles`: `roles` (array; the implicit baseline role is stripped, only explicitly assigned roles are exported)
|
||||
- scope `profile`: `profile` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"first_name": "...", "last_name": "...", "title": "...", "salutation": "...",
|
||||
"gender": "...", "date_of_birth": "1990-01-01", "nationality": "...",
|
||||
"address": {"street": "...", "postcode": "...", "city": "...", "district": "...", "country": "..."},
|
||||
"communication": {"email": "...", "mobile": "...", "phone": "...", "newsletter": true},
|
||||
"hotel_codes": ["..."], "height": null, "weight": null, "shoe_size": null, "remarks": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity.
|
||||
|
||||
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
||||
|
||||
#### `GET /api/crm-attributes` — scope `profile`
|
||||
|
||||
CRM selection attributes/segments for the authenticated user. `400 {"message": "...", "code": "..."}` on upstream failure.
|
||||
|
||||
---
|
||||
|
||||
## Client implementation notes
|
||||
|
||||
1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401.
|
||||
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
|
||||
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
|
||||
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
|
||||
5. **Server-side caching** (1 h on products and the contingent calendar) means upstream changes may not be visible immediately.
|
||||
6. **Route ambiguity**: `/api/travels/{a}/{b}` resolves to the id-based route when both segments are numeric, otherwise to the code-based route. Non-numeric codes are safe; purely numeric *codes* will hit the id route.
|
||||
@@ -9,8 +9,7 @@ use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* What it takes to provision a teamer from a received application: the applicant's gender, and the
|
||||
* outcome of each of the three provisioning writes
|
||||
* (.ddev/plans/application-form-teamer-provisioning.md).
|
||||
* outcome of each of the three provisioning writes.
|
||||
*
|
||||
* Emptying the table first is deliberate. gender is NOT NULL because BusPro refuses an address
|
||||
* without one and Teamer::$gender is not nullable either, and the existing rows predate the
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds the per-placement feedback waiver.
|
||||
*
|
||||
* The overdue-feedback lists are a live query without any stored state, so there was no way to
|
||||
* write off a feedback nobody will provide any more. The flag takes a placement out of the
|
||||
* dashboards' overdue lists, the house-manager list and the reminder mail.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261013120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add the feedback_waived flag to dispositions';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition ADD feedback_waived TINYINT DEFAULT 0 NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition DROP feedback_waived');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds the staffing matrix and what the assignment generator needs from the travel import.
|
||||
*
|
||||
* destination.product_code is the travel code MyE&P reports per date; destination.imported_at
|
||||
* tells the dates the latest import reported from ones cancelled upstream, which the import
|
||||
* never deletes. Both stay NULL until the next app:myep-import.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261015154754 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add the staffing_rule table and destination.product_code / imported_at';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE staffing_rule (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, product_code VARCHAR(32) NOT NULL, guides INT NOT NULL, ski_beginner TINYINT NOT NULL, ski_advanced TINYINT NOT NULL, split TINYINT NOT NULL, guiding TINYINT NOT NULL, bus_escort TINYINT NOT NULL, enabled TINYINT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, deleted_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_91DFB1A2D17F50A6 (uuid), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE destination ADD product_code VARCHAR(32) DEFAULT NULL, ADD imported_at DATETIME DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE staffing_rule');
|
||||
$this->addSql('ALTER TABLE destination DROP product_code, DROP imported_at');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drops the staffing matrix's Busbegleitung flag.
|
||||
*
|
||||
* Whether a date gets a bus escort follows from the date itself: one with boarding points has
|
||||
* bus travel, and App\Service\Assignment\AssignmentPlanner pre-selects the first of them. A flag
|
||||
* per product could only repeat that, and less precisely, since bus travel varies between the
|
||||
* dates of one product.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261016090000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop staffing_rule.bus_escort';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE staffing_rule DROP bus_escort');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE staffing_rule ADD bus_escort TINYINT NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds questionnaire_response: a respondent's answers to one questionnaire from
|
||||
* config/questionnaires/, for now the teamer questionnaire after a placement.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261027140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add questionnaire_response';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE questionnaire_response (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, questionnaire VARCHAR(64) NOT NULL, version INT NOT NULL, status VARCHAR(16) NOT NULL, submitted_at DATETIME DEFAULT NULL, answers JSON NOT NULL, context JSON NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, disposition_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_A0400276D17F50A6 (uuid), UNIQUE INDEX questionnaire_disposition (questionnaire, disposition_id), INDEX IDX_A0400276287B65ED (disposition_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`');
|
||||
$this->addSql('ALTER TABLE questionnaire_response ADD CONSTRAINT FK_A0400276287B65ED FOREIGN KEY (disposition_id) REFERENCES disposition (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE questionnaire_response DROP FOREIGN KEY FK_A0400276287B65ED');
|
||||
$this->addSql('DROP TABLE questionnaire_response');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Replaces the staffing matrix's split flag with one flag per ski level.
|
||||
*
|
||||
* A split row takes over every level it has, so it keeps planning the same assignments. The way
|
||||
* back folds the two flags into one, which loses a row that takes over one level of two.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261105100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Replace staffing_rule.split with split_beginner / split_advanced';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE staffing_rule ADD split_beginner TINYINT NOT NULL, ADD split_advanced TINYINT NOT NULL');
|
||||
$this->addSql('UPDATE staffing_rule SET split_beginner = split AND ski_beginner, split_advanced = split AND ski_advanced');
|
||||
$this->addSql('ALTER TABLE staffing_rule DROP split');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE staffing_rule ADD split TINYINT NOT NULL');
|
||||
$this->addSql('UPDATE staffing_rule SET split = split_beginner OR split_advanced');
|
||||
$this->addSql('ALTER TABLE staffing_rule DROP split_beginner, DROP split_advanced');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Numbers the places of an assignment, for the n8n placements feed ("platz_id").
|
||||
*
|
||||
* Existing active placements are numbered 1..n per assignment in creation order; called-off ones
|
||||
* stay without a number, they hold no place. n8n has not seen a place number yet, so the initial
|
||||
* order is free to choose.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261106100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add disposition.slot and number the active placements per assignment';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition ADD slot SMALLINT DEFAULT NULL');
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE disposition
|
||||
INNER JOIN (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY assignment_id ORDER BY id) AS slot
|
||||
FROM disposition
|
||||
WHERE status <> 'called_off' AND assignment_id IS NOT NULL
|
||||
) AS numbered ON numbered.id = disposition.id
|
||||
SET disposition.slot = numbered.slot
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition DROP slot');
|
||||
}
|
||||
}
|
||||
Generated
+1676
-5443
File diff suppressed because it is too large.
Load diff
+12
-14
@@ -1,39 +1,37 @@
|
||||
{
|
||||
"name": "myep-team",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.17.0",
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@babel/core": "^8.0.0",
|
||||
"@babel/preset-env": "^8.0.0",
|
||||
"@hotwired/stimulus": "^3.0.0",
|
||||
"@symfony/stimulus-bridge": "^3.2.0",
|
||||
"@symfony/webpack-encore": "^4.0.0",
|
||||
"@symfony/webpack-encore": "^7.2.0",
|
||||
"@tailwindcss/forms": "^0.5.6",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@trevoreyre/autocomplete-js": "^2.4.1",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"babel-plugin-polyfill-corejs3": "^1.0.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"core-js": "^3.23.0",
|
||||
"core-js": "^3.50.0",
|
||||
"dropzone": "^6.0.0-beta.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"flatpickr": "^4.6.13",
|
||||
"htmx.org": "^1.9.10",
|
||||
"htmx.org": "^2.0.11",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mjml": "^4.15.3",
|
||||
"postcss": "^8.4.29",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-loader": "^7.3.3",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"sortablejs": "^1.15.0",
|
||||
"stimulus-use": "^0.52.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tippy.js": "^6.3.7",
|
||||
"toastify-js": "^1.12.0",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-cli": "^4.10.0"
|
||||
"webpack": "^5.101.0",
|
||||
"webpack-cli": "^7.0.0"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev-server": "encore dev-server",
|
||||
"dev": "encore dev",
|
||||
"watch": "encore dev --watch",
|
||||
"build": "encore production --progress",
|
||||
|
||||
+2
-4
@@ -1,7 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,15 +68,28 @@ class ApiClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the teamer's profile to their BusPro address without their password.
|
||||
*
|
||||
* "login" switches the password check off, so the two ids are the whole authorisation of
|
||||
* this write: an address can hold several persons, and only "idadresseperson" names the
|
||||
* person, while "email" is ignored. The ids must therefore only ever come from a verified
|
||||
* source - MyE&P userinfo or a BusPro answer - never from a form or a request parameter.
|
||||
* Verified on the BusPro test system (2026-10-16): BusPro accepts "login" on KUNDENKONTO
|
||||
* requests made with our API credentials.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function updateProfile(User $user, string $password): NotificationResponse|ProfileUpdateResponse
|
||||
public function updateProfile(User $user): NotificationResponse|ProfileUpdateResponse
|
||||
{
|
||||
if (null === $teamer = $user->getTeamer()) {
|
||||
throw new ApiClientException('Invalid argument');
|
||||
}
|
||||
|
||||
if (null === $user->getBusProAddressId() || null === $user->getBusProPersonId()) {
|
||||
throw new ApiClientException('User has no BusPro address and person id');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'anfrage' => [
|
||||
'user' => $this->config['bpn_username'],
|
||||
@@ -84,8 +97,9 @@ class ApiClient
|
||||
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
|
||||
'art' => 'Adressdaten_Ändern',
|
||||
'email' => $user->getEmail(),
|
||||
'passwort' => $password,
|
||||
'login' => 'J',
|
||||
'idadresse' => $user->getBusProAddressId(),
|
||||
'idadresseperson' => $user->getBusProPersonId(),
|
||||
'adressdaten' => $teamer->toPayload(),
|
||||
],
|
||||
];
|
||||
@@ -132,8 +146,9 @@ class ApiClient
|
||||
* "login" switches the password check off, which only works when the request identifies the
|
||||
* person by id: both ids are required, and the person id is "idadresseperson" on the way in
|
||||
* although the response calls it "idperson". The element order and the single-selection
|
||||
* payload are what BusPro was verified to accept - the art is undocumented, see
|
||||
* .ddev/plans/buspro-crm-selection-write.md before changing the shape.
|
||||
* payload are what BusPro accepted when verified against production (2026-09-25, selection
|
||||
* 1070 toggled off and back on) - the art is undocumented, so re-verify before changing the
|
||||
* shape.
|
||||
*
|
||||
* Idempotent: setting a selection that is already set answers änderung=True and changes
|
||||
* nothing, so a caller may write unconditionally and take isUpdated() as the success check.
|
||||
|
||||
@@ -54,7 +54,7 @@ class ResponseParser
|
||||
// to be routed to createCrmAttributesResponse(), which walks a
|
||||
// "selektionsmerkmale" node that is absent here and would have returned an
|
||||
// empty response with every role flag false - dead code until something
|
||||
// sent the art (.ddev/plans/buspro-crm-selection-write.md).
|
||||
// sent the art.
|
||||
return $this->createProfileUpdateResponse($xml);
|
||||
case 'Adresse_Neu':
|
||||
return $this->createRegistrationResponse($xml);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Model\PlannedAssignment;
|
||||
use App\Model\StaffingCandidate;
|
||||
use App\Service\Assignment\AssignmentGenerator;
|
||||
use App\Service\Assignment\StaffingConfigurationException;
|
||||
use App\Service\Assignment\StaffingResolver;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Creates draft assignments for the future dates of every enabled staffing matrix row.
|
||||
*
|
||||
* Safe to repeat: a date that has ever had an assignment is skipped, so drafts the office
|
||||
* edited or deleted are never recreated. Run it after app:myep-import - it only considers
|
||||
* the dates the latest import reported.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:assignment-generate',
|
||||
description: 'Creates draft assignments from the staffing matrix',
|
||||
)]
|
||||
class AssignmentGenerateCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AssignmentGenerator $generator,
|
||||
private readonly StaffingResolver $resolver,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only list what would be created');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = true === $input->getOption('dry-run');
|
||||
|
||||
try {
|
||||
$this->resolver->assertConfigured();
|
||||
} catch (StaffingConfigurationException $e) {
|
||||
$io->error('Jobprofil oder Honorar der Besetzungsmatrix nicht gefunden – abgebrochen: '.$e->getMessage());
|
||||
$this->logger->error('Assignment generation aborted', ['reason' => $e->getMessage()]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$candidates = $this->generator->getCandidates(enabledOnly: true);
|
||||
$open = array_values(array_filter($candidates, static fn (StaffingCandidate $c): bool => false === $c->skipped));
|
||||
|
||||
$io->info(sprintf(
|
||||
'%d Reisetermine aus der Besetzungsmatrix, davon %d schon mit Einsätzen (übersprungen)',
|
||||
count($candidates),
|
||||
count($candidates) - count($open),
|
||||
));
|
||||
|
||||
if ([] === $open) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->table(
|
||||
['Termin', 'Produkt', 'Beginn', 'Einsätze'],
|
||||
array_map($this->describe(...), $open),
|
||||
);
|
||||
|
||||
if (true === $dryRun) {
|
||||
$io->note('Probelauf – es wurde nichts angelegt.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
|
||||
foreach ($open as $candidate) {
|
||||
$created += count($this->generator->generate($candidate));
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d Einsätze für %d Reisetermine als Entwurf angelegt', $created, count($open)));
|
||||
$this->logger->info('Assignment generation finished', [
|
||||
'destinations' => count($open),
|
||||
'assignments' => $created,
|
||||
'skipped' => count($candidates) - count($open),
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function describe(StaffingCandidate $candidate): array
|
||||
{
|
||||
return [
|
||||
$candidate->destination->getCode(),
|
||||
$candidate->rule->getProductCode(),
|
||||
$candidate->destination->getDateFrom()->format('d.m.Y'),
|
||||
implode("\n", array_map(
|
||||
fn (PlannedAssignment $planned): string => sprintf(
|
||||
'%d× %s%s',
|
||||
$planned->places,
|
||||
$this->resolver->jobProfile($planned->role)->getName(),
|
||||
null === $planned->pickup ? '' : ' (Busbegleitung)',
|
||||
),
|
||||
$candidate->planned,
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\ConditionOperator;
|
||||
use App\Enum\QuestionType;
|
||||
use App\Model\Questionnaire\Condition;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use App\Model\Questionnaire\Option;
|
||||
use App\Model\Questionnaire\Question;
|
||||
use App\Model\Questionnaire\Section;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* Reads questionnaire definitions from config/questionnaires/.
|
||||
*
|
||||
* `<key>.yaml` is the current version of a questionnaire; `<key>.v<n>.yaml` keeps an older one that
|
||||
* responses were submitted under, so they can still be rendered with the labels they were given.
|
||||
*
|
||||
* Files are parsed lazily and memoised, like EmailTextCatalog. A definition is checked strictly
|
||||
* while it is built - unknown fields, unknown types, duplicate keys and show_if conditions that
|
||||
* point at a later question or a non-existent option all throw - because a typo in the YAML should
|
||||
* fail in QuestionnaireCatalogTest, not in front of a teamer.
|
||||
*/
|
||||
class QuestionnaireCatalog
|
||||
{
|
||||
/**
|
||||
* The questionnaire every teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
|
||||
*/
|
||||
public const string TEAMER_FEEDBACK = 'teamer_feedback';
|
||||
|
||||
/**
|
||||
* The one a teamer who escorted the bus fills in as well, on the same page.
|
||||
*/
|
||||
public const string BUS_FEEDBACK = 'bus_feedback';
|
||||
|
||||
private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections'];
|
||||
private const array SECTION_FIELDS = ['key', 'title', 'questions'];
|
||||
private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if'];
|
||||
private const array OPTION_FIELDS = ['key', 'label'];
|
||||
|
||||
// A question key becomes a form field name and a JSON key in stored answers.
|
||||
private const string KEY_PATTERN = '/^[a-z][a-z0-9_]*$/';
|
||||
|
||||
private const int RATING_MIN = 1;
|
||||
private const int RATING_MAX = 5;
|
||||
|
||||
/**
|
||||
* @var array<string, Definition> keyed by file path
|
||||
*/
|
||||
private array $definitions = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $questionnaireDir,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The first end date the teamer questionnaire is still offered for: the services.yaml
|
||||
* parameter questionnaire_window_days, in days before today. Placements that ended earlier are neither
|
||||
* pending nor open, so a deploy does not offer it for every past placement at once.
|
||||
*/
|
||||
public static function windowStart(int $windowDays): \DateTimeImmutable
|
||||
{
|
||||
if ($windowDays < 0) {
|
||||
throw new \InvalidArgumentException(sprintf('questionnaire_window_days must not be negative, got %d', $windowDays));
|
||||
}
|
||||
|
||||
return new \DateTimeImmutable(sprintf('today -%d days', $windowDays));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $version null for the current version
|
||||
*/
|
||||
public function get(string $key, ?int $version = null): Definition
|
||||
{
|
||||
$current = $this->load($this->questionnaireDir.'/'.$key.'.yaml');
|
||||
|
||||
if (null === $version || $version === $current->version) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
return $this->load(sprintf('%s/%s.v%d.yaml', $this->questionnaireDir, $key, $version));
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys of the questionnaires there are, i.e. of their current files.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function keys(): array
|
||||
{
|
||||
$keys = [];
|
||||
|
||||
foreach (glob($this->questionnaireDir.'/*.yaml') ?: [] as $file) {
|
||||
if (1 === preg_match('/^([a-z0-9_]+)\.yaml$/', basename($file), $match)) {
|
||||
$keys[] = $match[1];
|
||||
}
|
||||
}
|
||||
|
||||
sort($keys);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every definition in the directory, archived versions included.
|
||||
*
|
||||
* @return list<Definition>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$files = glob($this->questionnaireDir.'/*.yaml') ?: [];
|
||||
sort($files);
|
||||
|
||||
return array_map($this->load(...), $files);
|
||||
}
|
||||
|
||||
private function load(string $file): Definition
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
throw new \InvalidArgumentException(sprintf('Questionnaire definition "%s" does not exist', $file));
|
||||
}
|
||||
|
||||
return $this->definitions[$file] ??= $this->build(Yaml::parseFile($file), basename($file));
|
||||
}
|
||||
|
||||
private function build(mixed $data, string $file): Definition
|
||||
{
|
||||
$this->assertFields($data, self::ROOT_FIELDS, ['key', 'version', 'title', 'sections'], $file);
|
||||
|
||||
if (1 !== preg_match('/^(?<key>[a-z0-9_]+)(\.v(?<version>\d+))?\.yaml$/', $file, $name)) {
|
||||
throw $this->error($file, 'file name must be <key>.yaml or <key>.v<version>.yaml');
|
||||
}
|
||||
|
||||
if ($data['key'] !== $name['key']) {
|
||||
throw $this->error($file, sprintf('key "%s" does not match the file name', $data['key']));
|
||||
}
|
||||
|
||||
if (!is_int($data['version']) || $data['version'] < 1) {
|
||||
throw $this->error($file, 'version must be a positive integer');
|
||||
}
|
||||
|
||||
if (isset($name['version']) && (int) $name['version'] !== $data['version']) {
|
||||
throw $this->error($file, sprintf('version %d does not match the file name', $data['version']));
|
||||
}
|
||||
|
||||
if (!is_array($data['sections']) || [] === $data['sections'] || !array_is_list($data['sections'])) {
|
||||
throw $this->error($file, 'sections must be a non-empty list');
|
||||
}
|
||||
|
||||
$sections = [];
|
||||
$sectionKeys = [];
|
||||
/** @var array<string, Question> $questions every question parsed so far, for show_if */
|
||||
$questions = [];
|
||||
|
||||
foreach ($data['sections'] as $index => $sectionData) {
|
||||
$context = sprintf('%s: section #%d', $file, $index + 1);
|
||||
$this->assertFields($sectionData, self::SECTION_FIELDS, ['key', 'questions'], $context);
|
||||
$key = $this->key($sectionData['key'], $context);
|
||||
|
||||
if (in_array($key, $sectionKeys, true)) {
|
||||
throw $this->error($context, sprintf('duplicate section key "%s"', $key));
|
||||
}
|
||||
|
||||
$sectionKeys[] = $key;
|
||||
$context = sprintf('%s: section "%s"', $file, $key);
|
||||
|
||||
if (!is_array($sectionData['questions']) || [] === $sectionData['questions'] || !array_is_list($sectionData['questions'])) {
|
||||
throw $this->error($context, 'questions must be a non-empty list');
|
||||
}
|
||||
|
||||
$sectionQuestions = [];
|
||||
|
||||
foreach ($sectionData['questions'] as $questionData) {
|
||||
$question = $this->buildQuestion($questionData, $questions, $context);
|
||||
|
||||
$questions[$question->key] = $question;
|
||||
$sectionQuestions[] = $question;
|
||||
}
|
||||
|
||||
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions);
|
||||
}
|
||||
|
||||
return new Definition(
|
||||
$data['key'],
|
||||
$data['version'],
|
||||
$this->string($data['title'], 'title', $file),
|
||||
$this->optionalString($data['description'] ?? null, 'description', $file),
|
||||
$sections,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Question> $earlier
|
||||
*/
|
||||
private function buildQuestion(mixed $data, array $earlier, string $context): Question
|
||||
{
|
||||
$this->assertFields($data, self::QUESTION_FIELDS, ['key', 'label', 'type'], $context.': question');
|
||||
$key = $this->key($data['key'], $context.': question');
|
||||
$context = sprintf('%s: question "%s"', $context, $key);
|
||||
|
||||
if (isset($earlier[$key])) {
|
||||
throw $this->error($context, 'duplicate question key');
|
||||
}
|
||||
|
||||
$type = QuestionType::tryFrom((string) $data['type'])
|
||||
?? throw $this->error($context, sprintf('unknown type "%s"', $data['type']));
|
||||
|
||||
$required = $data['required'] ?? false;
|
||||
|
||||
if (!is_bool($required)) {
|
||||
throw $this->error($context, 'required must be true or false');
|
||||
}
|
||||
|
||||
$widget = null;
|
||||
|
||||
if (array_key_exists('widget', $data)) {
|
||||
if (QuestionType::CHOICE !== $type) {
|
||||
throw $this->error($context, 'widget is only allowed on a choice question');
|
||||
}
|
||||
|
||||
$widget = ChoiceWidget::tryFrom((string) $data['widget'])
|
||||
?? throw $this->error($context, sprintf('unknown widget "%s"', $data['widget']));
|
||||
} elseif (QuestionType::CHOICE === $type) {
|
||||
$widget = ChoiceWidget::RADIO;
|
||||
}
|
||||
|
||||
$options = [];
|
||||
|
||||
if ($type->hasOptions()) {
|
||||
$options = $this->buildOptions($data['options'] ?? null, $context);
|
||||
} elseif (array_key_exists('options', $data)) {
|
||||
throw $this->error($context, sprintf('options are not allowed on a %s question', $type->value));
|
||||
}
|
||||
|
||||
$showIf = array_key_exists('show_if', $data)
|
||||
? $this->buildCondition($data['show_if'], $earlier, $context)
|
||||
: null;
|
||||
|
||||
return new Question(
|
||||
$key,
|
||||
$this->string($data['label'], 'label', $context),
|
||||
$type,
|
||||
$required,
|
||||
$this->optionalString($data['help'] ?? null, 'help', $context),
|
||||
$widget,
|
||||
$options,
|
||||
$showIf,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Option>
|
||||
*/
|
||||
private function buildOptions(mixed $data, string $context): array
|
||||
{
|
||||
if (!is_array($data) || [] === $data || !array_is_list($data)) {
|
||||
throw $this->error($context, 'options must be a non-empty list');
|
||||
}
|
||||
|
||||
$options = [];
|
||||
$keys = [];
|
||||
|
||||
foreach ($data as $optionData) {
|
||||
$this->assertFields($optionData, self::OPTION_FIELDS, self::OPTION_FIELDS, $context.': option');
|
||||
// An option key is stored as the answer; YAML would turn an unquoted 1 into an int.
|
||||
$key = $this->key((string) $optionData['key'], $context.': option');
|
||||
|
||||
if (in_array($key, $keys, true)) {
|
||||
throw $this->error($context, sprintf('duplicate option key "%s"', $key));
|
||||
}
|
||||
|
||||
$keys[] = $key;
|
||||
$options[] = new Option($key, $this->string($optionData['label'], 'option label', $context));
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Question> $earlier
|
||||
*/
|
||||
private function buildCondition(mixed $data, array $earlier, string $context): Condition
|
||||
{
|
||||
$context .= ': show_if';
|
||||
$operators = array_map(static fn (ConditionOperator $operator): string => $operator->value, ConditionOperator::cases());
|
||||
$this->assertFields($data, ['question', ...$operators], ['question'], $context);
|
||||
|
||||
$given = array_values(array_intersect($operators, array_keys($data)));
|
||||
|
||||
if (1 !== count($given)) {
|
||||
throw $this->error($context, sprintf('needs exactly one of %s', implode(', ', $operators)));
|
||||
}
|
||||
|
||||
$operator = ConditionOperator::from($given[0]);
|
||||
$value = $data[$given[0]];
|
||||
|
||||
// Only an earlier question can be answered by the time this one is reached, and it rules
|
||||
// out cycles without having to look for them.
|
||||
$controlling = $earlier[$data['question']]
|
||||
?? throw $this->error($context, sprintf('question "%s" is not an earlier question', $data['question']));
|
||||
|
||||
match ($operator) {
|
||||
ConditionOperator::EQUALS, ConditionOperator::IN => $this->assertOptionValue($controlling, $operator, $value, $context),
|
||||
ConditionOperator::LTE => $this->assertRatingValue($controlling, $value, $context),
|
||||
};
|
||||
|
||||
return new Condition($controlling->key, $operator, $value);
|
||||
}
|
||||
|
||||
private function assertOptionValue(Question $controlling, ConditionOperator $operator, mixed $value, string $context): void
|
||||
{
|
||||
if (QuestionType::CHOICE !== $controlling->type) {
|
||||
throw $this->error($context, sprintf('%s needs a choice question, "%s" is %s', $operator->value, $controlling->key, $controlling->type->value));
|
||||
}
|
||||
|
||||
$values = ConditionOperator::IN === $operator ? $value : [$value];
|
||||
|
||||
if (!is_array($values) || [] === $values || !array_is_list($values)) {
|
||||
throw $this->error($context, 'in needs a non-empty list of option keys');
|
||||
}
|
||||
|
||||
foreach ($values as $optionKey) {
|
||||
if (!is_string($optionKey) || null === $controlling->getOption($optionKey)) {
|
||||
throw $this->error($context, sprintf('"%s" is not an option of "%s"', is_string($optionKey) ? $optionKey : json_encode($optionKey), $controlling->key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertRatingValue(Question $controlling, mixed $value, string $context): void
|
||||
{
|
||||
if (QuestionType::RATING !== $controlling->type) {
|
||||
throw $this->error($context, sprintf('lte needs a rating question, "%s" is %s', $controlling->key, $controlling->type->value));
|
||||
}
|
||||
|
||||
if (!is_int($value) || $value < self::RATING_MIN || $value > self::RATING_MAX) {
|
||||
throw $this->error($context, sprintf('lte needs a rating from %d to %d', self::RATING_MIN, self::RATING_MAX));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $allowed
|
||||
* @param list<string> $required
|
||||
*/
|
||||
private function assertFields(mixed $data, array $allowed, array $required, string $context): void
|
||||
{
|
||||
if (!is_array($data) || array_is_list($data)) {
|
||||
throw $this->error($context, 'must be a mapping');
|
||||
}
|
||||
|
||||
if ([] !== $unknown = array_diff(array_keys($data), $allowed)) {
|
||||
throw $this->error($context, sprintf('unknown field(s) %s', implode(', ', $unknown)));
|
||||
}
|
||||
|
||||
if ([] !== $missing = array_diff($required, array_keys($data))) {
|
||||
throw $this->error($context, sprintf('missing field(s) %s', implode(', ', $missing)));
|
||||
}
|
||||
}
|
||||
|
||||
private function key(mixed $value, string $context): string
|
||||
{
|
||||
if (!is_string($value) || 1 !== preg_match(self::KEY_PATTERN, $value)) {
|
||||
throw $this->error($context, sprintf('key %s must be lower snake_case', json_encode($value)));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function string(mixed $value, string $field, string $context): string
|
||||
{
|
||||
if (!is_string($value) || '' === trim($value)) {
|
||||
throw $this->error($context, sprintf('%s must be a non-empty string', $field));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function optionalString(mixed $value, string $field, string $context): ?string
|
||||
{
|
||||
return null === $value ? null : $this->string($value, $field, $context);
|
||||
}
|
||||
|
||||
private function error(string $context, string $message): \UnexpectedValueException
|
||||
{
|
||||
return new \UnexpectedValueException(sprintf('%s: %s', $context, $message));
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ namespace App\Controller\Admin\Teamer;
|
||||
use App\Entity\Teamer;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Repository\ApplicationRepository;
|
||||
use App\Repository\DispositionRepository;
|
||||
use App\Service\Teamer\AccountDeletionHandler;
|
||||
use App\Service\Teamer\UpcomingPlacementCollector;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -18,7 +18,7 @@ class DeleteAccountController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly AccountDeletionHandler $accountDeletionHandler,
|
||||
private readonly ApplicationRepository $applicationRepository,
|
||||
private readonly DispositionRepository $dispositionRepository,
|
||||
private readonly UpcomingPlacementCollector $upcomingPlacementCollector,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ class DeleteAccountController extends AbstractController
|
||||
return $this->render('admin/teamer/modal_delete_account.html.twig', [
|
||||
'teamer' => $teamer,
|
||||
'pendingApplicationCount' => count($this->applicationRepository->getPendingForTeamer($teamer, 100)),
|
||||
'upcomingDispositionCount' => count($this->dispositionRepository->getUpcomingDispositionsByTeamer($teamer, 100)),
|
||||
// season assignments included: an active season placement is an open commitment as well
|
||||
'upcomingPlacementCount' => count($this->upcomingPlacementCollector->collect($teamer)),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Controller\Administrative\Disposition;
|
||||
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Event\DispositionCalledOffEvent;
|
||||
use App\Form\DispositionCallOffType;
|
||||
@@ -33,7 +32,7 @@ class CallOffController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$disposition->setStatus(Assignment::STATUS_CALLED_OFF);
|
||||
$disposition->setStatus(Disposition::STATUS_CALLED_OFF);
|
||||
$sendNotification = $form->get('sendNotification')->getData();
|
||||
|
||||
$this->entityManager->flush();
|
||||
@@ -46,7 +45,7 @@ class CallOffController extends AbstractController
|
||||
$assignment = $disposition->getAssignment();
|
||||
|
||||
$this->logger->info('Call off disposition', [
|
||||
'assignment_id' => $disposition->getId(),
|
||||
'disposition_id' => $disposition->getId(),
|
||||
'destination' => (string) $assignment->getDestination(),
|
||||
'job_profile' => $assignment->getJobProfile()->getName(),
|
||||
]);
|
||||
|
||||
@@ -13,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Flagception\Manager\FeatureManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -49,6 +50,12 @@ class DocumentUploadController extends AbstractController
|
||||
$form = $this->createFormBuilder()->getForm();
|
||||
$form->handleRequest($request);
|
||||
|
||||
// Checked before processUpload() removes the existing document, which it replaces.
|
||||
if ($form->isSubmitted()
|
||||
&& true === $this->uploadHandler->getUploadSession()->getUploadsByType($type)->isEmpty()) {
|
||||
$form->addError(new FormError('Bitte lade zuerst eine Datei hoch'));
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->processUpload($disposition, $type);
|
||||
|
||||
@@ -110,15 +117,17 @@ class DocumentUploadController extends AbstractController
|
||||
|
||||
$disposition->addDocument($upload);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage($type, $uploadSession);
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType($type);
|
||||
|
||||
// Before the flush: converting a non-PDF renames the file, and the new filename has to
|
||||
// be persisted with the row.
|
||||
if (true === $this->featureManager->isActive('sanitize_uploads')) {
|
||||
$this->uploadHandler->ensurePdf($upload);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->eventDispatcher->dispatch(new DocumentUploadedEvent($upload), DocumentUploadedEvent::NAME);
|
||||
if (Upload::TYPE_CONTRACT === $type) {
|
||||
$this->eventDispatcher->dispatch(new DocumentConfirmedEvent($upload), DocumentConfirmedEvent::NAME);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\Questionnaire;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\QuestionnaireResponse;
|
||||
use App\Repository\QuestionnaireResponseRepository;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Requirement\Requirement;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* One submitted questionnaire: what the disposition knew when it was submitted, then the answers,
|
||||
* rendered with the definition version they were given under.
|
||||
*/
|
||||
class DetailController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly QuestionnaireCatalog $catalog,
|
||||
private readonly QuestionnaireResponseRepository $responseRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
// the requirement keeps /administrative/questionnaire/list from being read as a uuid
|
||||
#[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail', requirements: ['uuid' => Requirement::UUID])]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
QuestionnaireResponse $response,
|
||||
Request $request,
|
||||
): Response {
|
||||
// a draft is still the teamer's
|
||||
if (false === $response->isSubmitted()) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
// the other questionnaire of the same placement, e.g. the bus feedback beside the teamer feedback
|
||||
$siblings = array_filter(
|
||||
$this->responseRepository->findByDisposition($response->getDisposition()),
|
||||
static fn (QuestionnaireResponse $sibling): bool => $sibling !== $response && $sibling->isSubmitted(),
|
||||
);
|
||||
|
||||
return $this->render('administrative/questionnaire/detail.html.twig', [
|
||||
'definition' => $this->catalog->get($response->getQuestionnaire(), $response->getVersion()),
|
||||
'response' => $response,
|
||||
'siblings' => array_map(fn (QuestionnaireResponse $sibling): array => [
|
||||
'response' => $sibling,
|
||||
'title' => $this->catalog->get($sibling->getQuestionnaire(), $sibling->getVersion())->title,
|
||||
], array_values($siblings)),
|
||||
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_questionnaire_index'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\Questionnaire;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Repository\QuestionnaireResponseRepository;
|
||||
use App\Service\Common\QuestionnaireResponseFilterHandler;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The submitted responses to one questionnaire, teamer or bus feedback. Only administrative users
|
||||
* read them: they rate the house manager among others, so house managers are deliberately not
|
||||
* let in.
|
||||
*/
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QuestionnaireResponseFilterHandler $filterHandler,
|
||||
private readonly QuestionnaireResponseRepository $responseRepository,
|
||||
private readonly QuestionnaireCatalog $catalog,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(
|
||||
'/administrative/questionnaire/list/{questionnaire}',
|
||||
name: 'app_administrative_questionnaire_index',
|
||||
requirements: ['questionnaire' => '[a-z0-9_]+'],
|
||||
defaults: ['questionnaire' => QuestionnaireCatalog::TEAMER_FEEDBACK],
|
||||
)]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(string $questionnaire, Request $request): Response
|
||||
{
|
||||
$keys = $this->catalog->keys();
|
||||
|
||||
if (!in_array($questionnaire, $keys, true)) {
|
||||
throw $this->createNotFoundException(sprintf('No questionnaire "%s"', $questionnaire));
|
||||
}
|
||||
|
||||
$filterDto = $this->filterHandler->getFilterSettings();
|
||||
|
||||
$pagination = $this->paginator->paginate(
|
||||
$this->responseRepository->getListQuery($questionnaire, $filterDto),
|
||||
$request->query->getInt('page', 1),
|
||||
$request->query->getInt('limit', 50),
|
||||
[
|
||||
'defaultSortFieldName' => 'response.submittedAt',
|
||||
'defaultSortDirection' => 'desc',
|
||||
]
|
||||
);
|
||||
|
||||
return $this->render('administrative/questionnaire/index.html.twig', [
|
||||
'definition' => $this->catalog->get($questionnaire),
|
||||
// for switching between the questionnaires
|
||||
'definitions' => array_map($this->catalog->get(...), $keys),
|
||||
'pagination' => $pagination,
|
||||
'filterDto' => $filterDto,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\Questionnaire;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\Assignment;
|
||||
use App\Form\QuestionnaireType;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The questionnaires as a teamer gets them, for trying out on any assignment - before or after the
|
||||
* trip, placed or not. It shows what a placement on this assignment gets - the teamer feedback,
|
||||
* and the bus feedback too for a bus escort; ?bus_escort=1 or 0 tries the other case. Nothing is
|
||||
* saved: "Absenden" shows the answers as they would be stored, "Entwurf speichern" just keeps them
|
||||
* in the form.
|
||||
*/
|
||||
class TestController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly QuestionnaireCatalog $catalog,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/questionnaire/test/{uuid}', name: 'app_administrative_questionnaire_test')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
Assignment $assignment,
|
||||
Request $request,
|
||||
): Response {
|
||||
$busEscort = $request->query->has('bus_escort')
|
||||
? $request->query->getBoolean('bus_escort')
|
||||
: $assignment->hasBusEscort();
|
||||
$definitions = [$this->catalog->get(QuestionnaireCatalog::TEAMER_FEEDBACK)];
|
||||
|
||||
if ($busEscort) {
|
||||
$definitions[] = $this->catalog->get(QuestionnaireCatalog::BUS_FEEDBACK);
|
||||
}
|
||||
|
||||
$form = $this->createForm(QuestionnaireType::class, [], ['definitions' => $definitions]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$submitted = null;
|
||||
$draft = false;
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (QuestionnaireType::isFinal($form)) {
|
||||
$submitted = array_map(static fn (Definition $definition): array => [
|
||||
'definition' => $definition,
|
||||
'answers' => $definition->withoutHiddenAnswers($form->getData()[$definition->key] ?? []),
|
||||
], $definitions);
|
||||
} else {
|
||||
$draft = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('administrative/questionnaire/test.html.twig', [
|
||||
'assignment' => $assignment,
|
||||
'form' => $form,
|
||||
'submitted' => $submitted,
|
||||
'draft' => $draft,
|
||||
'busEscort' => $busEscort,
|
||||
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_assignment_index'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ class TaskDeadlineController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$applyToAll = true === $form->get('applyToAll')->getData();
|
||||
$appliedCount = $applyToAll ? ($seasonAssignment?->applyDeadlinesToOpenTasks($task) ?? 0) : 0;
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Change season assignment task deadlines', [
|
||||
@@ -54,9 +57,13 @@ class TaskDeadlineController extends AbstractController
|
||||
'task' => $task->getTaskKey(),
|
||||
'deadline_override_1' => $task->getDeadlineOverride1()?->format('Y-m-d'),
|
||||
'deadline_override_2' => $task->getDeadlineOverride2()?->format('Y-m-d'),
|
||||
'apply_to_all' => $applyToAll,
|
||||
'applied_count' => $appliedCount,
|
||||
]);
|
||||
|
||||
$this->addFlash('success', 'Die Fristen wurden gespeichert');
|
||||
$this->addFlash('success', $applyToAll
|
||||
? 'Die Fristen wurden für alle offenen Einträge gespeichert'
|
||||
: 'Die Fristen wurden gespeichert');
|
||||
|
||||
return new HxRedirectResponse($returnUrl);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Controller\Administrative\System\Document;
|
||||
|
||||
use App\Entity\Upload;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Repository\SeasonTaskDocumentRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -17,14 +19,32 @@ class DeleteController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/system/document/delete/{uuid}', name: 'app_administrative_system_document_delete')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
#[IsGranted('DELETE', subject: 'document')]
|
||||
public function index(Upload $document, Request $request): Response
|
||||
{
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
Upload $document,
|
||||
Request $request,
|
||||
): Response {
|
||||
// A document handed out for a season task is referenced by a NOT NULL foreign key, so
|
||||
// deleting it fails in the database. It has to be replaced in that mapping first.
|
||||
$seasonTaskDocuments = $this->seasonTaskDocumentRepository->findByDocument($document);
|
||||
|
||||
if (0 < count($seasonTaskDocuments) && true === $request->isMethod('POST')) {
|
||||
$this->addFlash('error', 'Das Dokument ist als Saisonteam-Dokument hinterlegt und kann nicht gelöscht werden');
|
||||
$this->logger->info('Refuse deleting document assigned to a season task', [
|
||||
'document_id' => $document->getId(),
|
||||
'document_filename' => $document->getOriginalFilename(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_system_document_index'));
|
||||
}
|
||||
|
||||
if (true === $request->isMethod('POST')) {
|
||||
$this->entityManager->remove($document);
|
||||
$this->entityManager->flush();
|
||||
@@ -40,6 +60,7 @@ class DeleteController extends AbstractController
|
||||
|
||||
return $this->render('administrative/system/document/modal_delete.html.twig', [
|
||||
'document' => $document,
|
||||
'seasonTaskDocuments' => $seasonTaskDocuments,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller\Administrative\System\Document;
|
||||
|
||||
use App\Repository\SeasonTaskDocumentRepository;
|
||||
use App\Repository\UploadRepository;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -15,6 +16,7 @@ class IndexController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly UploadRepository $uploadRepository,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ class IndexController extends AbstractController
|
||||
|
||||
return $this->render('administrative/system/document/index.html.twig', [
|
||||
'pagination' => $pagination,
|
||||
'seasonTaskDocuments' => $this->seasonTaskDocumentRepository->findGroupedByDocumentId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Entity\StaffingRule;
|
||||
use App\Form\StaffingRuleType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class CreateController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/system/staffing-rule/create', name: 'app_administrative_system_staffing_rule_create')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$rule = new StaffingRule();
|
||||
$form = $this->createForm(StaffingRuleType::class, $rule);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->persist($rule);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde angelegt');
|
||||
$this->logger->info('Create staffing rule', [
|
||||
'staffing_rule_id' => $rule->getId(),
|
||||
'product_code' => $rule->getProductCode(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_system_staffing_rule_index'));
|
||||
}
|
||||
|
||||
return $this->render('administrative/system/staffing_rule/modal_create.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Entity\StaffingRule;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class DeleteController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/system/staffing-rule/delete/{uuid}', name: 'app_administrative_system_staffing_rule_delete')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(StaffingRule $rule, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod('POST')) {
|
||||
$rule->setDeleted();
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde gelöscht');
|
||||
$this->logger->info('Delete staffing rule', [
|
||||
'staffing_rule_id' => $rule->getId(),
|
||||
'product_code' => $rule->getProductCode(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_system_staffing_rule_index'));
|
||||
}
|
||||
|
||||
return $this->render('administrative/system/staffing_rule/modal_delete.html.twig', [
|
||||
'rule' => $rule,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Entity\StaffingRule;
|
||||
use App\Form\StaffingRuleType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/system/staffing-rule/edit/{uuid}', name: 'app_administrative_system_staffing_rule_edit')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(StaffingRule $rule, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(StaffingRuleType::class, $rule);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde aktualisiert');
|
||||
$this->logger->info('Edit staffing rule', [
|
||||
'staffing_rule_id' => $rule->getId(),
|
||||
'product_code' => $rule->getProductCode(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_system_staffing_rule_index'));
|
||||
}
|
||||
|
||||
return $this->render('administrative/system/staffing_rule/modal_edit.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Service\Assignment\StaffingConfigurationException;
|
||||
use App\Service\Export\StaffingPreviewExporter;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class ExportController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffingPreviewExporter $previewExporter,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/system/staffing-rule/export', name: 'app_administrative_system_staffing_rule_export')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(): Response
|
||||
{
|
||||
try {
|
||||
$rows = $this->previewExporter->rows();
|
||||
} catch (StaffingConfigurationException $e) {
|
||||
$this->addFlash('error', 'Ein Jobprofil oder Honorar der Besetzungsmatrix ist nicht (mehr) vorhanden: '.$e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('app_administrative_system_staffing_rule_index');
|
||||
}
|
||||
|
||||
if ([] === $rows) {
|
||||
$this->addFlash('error', 'Keine Daten für den Export vorhanden');
|
||||
|
||||
return $this->redirectToRoute('app_administrative_system_staffing_rule_preview');
|
||||
}
|
||||
|
||||
$spreadsheet = $this->previewExporter->export($rows);
|
||||
$filename = sprintf('besetzungsmatrix-vorschau_%s.xlsx', date('YmdHi'));
|
||||
|
||||
$response = new StreamedResponse(static function () use ($spreadsheet): void {
|
||||
(new Xlsx($spreadsheet))->save('php://output');
|
||||
});
|
||||
|
||||
$response->headers->set(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
);
|
||||
$response->headers->set(
|
||||
'Content-Disposition',
|
||||
HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename, md5($filename))
|
||||
);
|
||||
|
||||
$this->logger->info('Export staffing preview', [
|
||||
'count' => count($rows),
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Repository\StaffingRuleRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly StaffingRuleRepository $staffingRuleRepository)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/administrative/system/staffing-rule', name: 'app_administrative_system_staffing_rule_index')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('administrative/system/staffing_rule/index.html.twig', [
|
||||
'rules' => $this->staffingRuleRepository->getList(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Service\Assignment\StaffingConfigurationException;
|
||||
use App\Service\Export\StaffingPreviewExporter;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class PreviewController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffingPreviewExporter $previewExporter,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/system/staffing-rule/preview', name: 'app_administrative_system_staffing_rule_preview')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$rows = $this->previewExporter->rows();
|
||||
} catch (StaffingConfigurationException $e) {
|
||||
$this->addFlash('error', 'Ein Jobprofil oder Honorar der Besetzungsmatrix ist nicht (mehr) vorhanden: '.$e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('app_administrative_system_staffing_rule_index');
|
||||
}
|
||||
|
||||
// The candidates are a computed list, not a query: page the finished rows. The Excel
|
||||
// export stays the full list.
|
||||
$pagination = $this->paginator->paginate(
|
||||
$rows,
|
||||
$request->query->getInt('page', 1),
|
||||
$request->query->getInt('limit', 50),
|
||||
);
|
||||
|
||||
return $this->render('administrative/system/staffing_rule/preview.html.twig', [
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -11,7 +11,7 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class RecentAssignmentsController extends AbstractController
|
||||
class RecentDispositionsController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DispositionRepository $dispositionRepository,
|
||||
@@ -19,7 +19,7 @@ class RecentAssignmentsController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/teamer/recent-assignments/{uuid}', name: 'app_administrative_teamer_recent_assignments')]
|
||||
#[Route('/administrative/teamer/recent-dispositions/{uuid}', name: 'app_administrative_teamer_recent_dispositions')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(Teamer $teamer, Request $request): Response
|
||||
{
|
||||
@@ -38,7 +38,7 @@ class RecentAssignmentsController extends AbstractController
|
||||
]
|
||||
);
|
||||
|
||||
return $this->render('administrative/teamer/recent_assignments.html.twig', [
|
||||
return $this->render('administrative/teamer/recent_dispositions.html.twig', [
|
||||
'teamer' => $teamer,
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
+8
-16
@@ -3,7 +3,7 @@
|
||||
namespace App\Controller\Administrative\Teamer;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Repository\DispositionRepository;
|
||||
use App\Service\Teamer\UpcomingPlacementCollector;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -11,34 +11,26 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class UpcomingAssignmentsController extends AbstractController
|
||||
class UpcomingDispositionsController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DispositionRepository $dispositionRepository,
|
||||
private readonly UpcomingPlacementCollector $upcomingPlacementCollector,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/teamer/upcoming-assignments/{uuid}', name: 'app_administrative_teamer_upcoming_assignments')]
|
||||
#[Route('/administrative/teamer/upcoming-dispositions/{uuid}', name: 'app_administrative_teamer_upcoming_dispositions')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(Teamer $teamer, Request $request): Response
|
||||
{
|
||||
$query = $this
|
||||
->dispositionRepository
|
||||
->getUpcomingDispositionsByTeamerQuery($teamer)
|
||||
;
|
||||
|
||||
// an array rather than a query: dispositions and season assignments are merged in PHP
|
||||
$pagination = $this->paginator->paginate(
|
||||
$query,
|
||||
$this->upcomingPlacementCollector->collect($teamer),
|
||||
$request->query->getInt('page', 1),
|
||||
10,
|
||||
[
|
||||
'defaultSortFieldName' => 'destination.dateFrom',
|
||||
'defaultSortDirection' => 'asc',
|
||||
]
|
||||
10
|
||||
);
|
||||
|
||||
return $this->render('administrative/teamer/upcoming_assignments.html.twig', [
|
||||
return $this->render('administrative/teamer/upcoming_dispositions.html.twig', [
|
||||
'teamer' => $teamer,
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
@@ -21,8 +21,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
*
|
||||
* The visitor waits for this response inside their own form submission and TYPO3 gives up after
|
||||
* a few seconds without retrying, so this stores the row and answers - notification mail and any
|
||||
* matching against known people belong in a worker, not here
|
||||
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §6).
|
||||
* matching against known people belong in a worker, not here.
|
||||
*/
|
||||
class ApplicationFormController extends AbstractController
|
||||
{
|
||||
@@ -58,7 +57,7 @@ class ApplicationFormController extends AbstractController
|
||||
'formLabel' => $submission->getFormLabel(),
|
||||
'pageUid' => $submission->getPageUid(),
|
||||
'submittedAt' => $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM),
|
||||
// the labels and the answers are personal data (§7), their number is not
|
||||
// the labels and the answers are personal data, their number is not
|
||||
'detailCount' => count($submission->getDetails()),
|
||||
'receiptSent' => $receiptSent,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\Assignment;
|
||||
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Upload;
|
||||
use App\Enum\PlaceStatus;
|
||||
use App\Model\AssignmentExportQueryDto;
|
||||
use App\Repository\AssignmentRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The assignments feed n8n reads: one row per place of the houses' guide, course and service-team
|
||||
* assignments overlapping a date range. A row is a place, not a person - re-staffing a place
|
||||
* changes the person on the same platz_id, and an open place has person_id null. A called-off
|
||||
* assignment is left out entirely, as is a place the office struck.
|
||||
*
|
||||
* The field names are the consumer's (German), the n8n workflow is built on them; the path and the
|
||||
* query parameters follow the app's English URL vocabulary, and errors are English, like every
|
||||
* response of the api firewall.
|
||||
*/
|
||||
class ListController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @param list<string> $houseCodes
|
||||
* @param list<int> $jobProfileIds
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly AssignmentRepository $assignmentRepository,
|
||||
private readonly array $houseCodes,
|
||||
private readonly array $jobProfileIds,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/assignments', name: 'app_api_assignment_list', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_API_ASSIGNMENTS')]
|
||||
public function index(
|
||||
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||
// Deliberately without a default: the resolver would hand that over unvalidated for a
|
||||
// request without any query string, instead of the 400 naming from and to.
|
||||
AssignmentExportQueryDto $query,
|
||||
): JsonResponse {
|
||||
$rows = [];
|
||||
|
||||
$assignments = $this->assignmentRepository->findForExport(
|
||||
$query->getFromDate(),
|
||||
$query->getToDate(),
|
||||
$this->houseCodes,
|
||||
$this->jobProfileIds,
|
||||
);
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
foreach ($assignment->getPlaces() as $slot => $disposition) {
|
||||
$rows[] = $this->row($assignment, $slot, $disposition);
|
||||
}
|
||||
}
|
||||
|
||||
// the range and the count, never the rows - they name people
|
||||
$this->logger->info('Assignments exported', [
|
||||
'from' => $query->from,
|
||||
'to' => $query->to,
|
||||
'assignments' => count($assignments),
|
||||
'places' => count($rows),
|
||||
]);
|
||||
|
||||
return new JsonResponse($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function row(Assignment $assignment, int $slot, ?Disposition $disposition): array
|
||||
{
|
||||
$destination = $assignment->getDestination();
|
||||
$jobProfile = $assignment->getJobProfile();
|
||||
$teamer = $disposition?->getTeamer();
|
||||
$code = (string) $destination->getCode();
|
||||
|
||||
return [
|
||||
'platz_id' => sprintf('%d-%d', $assignment->getId(), $slot),
|
||||
'einsatz_id' => $assignment->getId(),
|
||||
'person_id' => $teamer?->getId(),
|
||||
'vorname' => $teamer?->getFirstName(),
|
||||
'nachname' => $teamer?->getLastName(),
|
||||
'haus' => substr((string) $destination->getHotelCode(), 0, 3),
|
||||
'von' => $destination->getDateFrom()->format('Y-m-d'),
|
||||
'bis' => $destination->getDateTo()->format('Y-m-d'),
|
||||
'jobprofil_id' => $jobProfile->getId(),
|
||||
'jobprofil' => $jobProfile->getName(),
|
||||
'status' => PlaceStatus::fromDisposition($disposition)->value,
|
||||
'unter_26' => $this->isUnder26($disposition, $assignment),
|
||||
'vertrag_liegt_vor' => $this->hasCheckedContract($disposition),
|
||||
// Older codes carry a slash ("DPWBSP/030229"), current ones do not.
|
||||
'reise_code' => '' === $code ? null : str_replace('/', '', $code),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Under 26 on the first day of the assignment.
|
||||
*/
|
||||
private function isUnder26(?Disposition $disposition, Assignment $assignment): ?bool
|
||||
{
|
||||
if (null === $dateOfBirth = $disposition?->getTeamer()?->getDateOfBirth()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $dateOfBirth->modify('+26 years') > $assignment->getDestination()->getDateFrom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Only once the office has checked it - an uploaded contract alone does not count. Null where
|
||||
* there is no contract to have: an open place, or an assignment that skips the formalities.
|
||||
*/
|
||||
private function hasCheckedContract(?Disposition $disposition): ?bool
|
||||
{
|
||||
if (null === $disposition || true === $disposition->isSkipFormalities()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return true === $disposition->getDocumentByType(Upload::TYPE_CONTRACT)?->isConfirmed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Common;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Form\QuestionnaireResponseFilterType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Service\Common\QuestionnaireResponseFilterHandler;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* One filter for both questionnaire lists; it returns to the list it was opened from.
|
||||
*/
|
||||
class QuestionnaireResponseFilterController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(private readonly QuestionnaireResponseFilterHandler $filterHandler)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/common/questionnaire-response/filter', name: 'app_common_questionnaire_response_filter')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$formData = $this->filterHandler->getFilterSettings();
|
||||
$form = $this->createForm(QuestionnaireResponseFilterType::class, $formData);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->filterHandler->handleRequest($form);
|
||||
|
||||
return new HxRedirectResponse($this->getReturnUrl($request, 'app_administrative_questionnaire_index'));
|
||||
}
|
||||
|
||||
return $this->render('common/modal_questionnaire_response_filter.html.twig', [
|
||||
'filterForm' => $form->createView(),
|
||||
'filterDto' => $form->getData(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/common/questionnaire-response/filter/reset', name: 'app_common_questionnaire_response_filter_reset')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function reset(Request $request): Response
|
||||
{
|
||||
$this->filterHandler->resetFilterSettings();
|
||||
|
||||
return $this->redirect($this->getReturnUrl($request, 'app_administrative_questionnaire_index'));
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,14 @@ class DetailController extends AbstractController
|
||||
|
||||
private function uploadContract(Disposition $disposition, UploadSessionDto $uploadSession): void
|
||||
{
|
||||
// The session can hold files of other types, so a non-empty session alone does not
|
||||
// mean there is anything to replace the existing document with.
|
||||
if (true === $uploadSession->getUploadsByType(Upload::TYPE_CONTRACT)->isEmpty()) {
|
||||
$this->addFlash('error', 'Bitte lade zuerst den Honorarvertrag hoch');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $existingUpload = $disposition->getDocumentByType(Upload::TYPE_CONTRACT)) {
|
||||
$this->entityManager->remove($existingUpload);
|
||||
}
|
||||
@@ -116,6 +124,14 @@ class DetailController extends AbstractController
|
||||
|
||||
private function uploadInvoice(Disposition $disposition, UploadSessionDto $uploadSession): void
|
||||
{
|
||||
// The session can hold files of other types, so a non-empty session alone does not
|
||||
// mean there is anything to replace the existing document with.
|
||||
if (true === $uploadSession->getUploadsByType(Upload::TYPE_INVOICE)->isEmpty()) {
|
||||
$this->addFlash('error', 'Bitte lade zuerst die Honorarnote hoch');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $existingUpload = $disposition->getDocumentByType(Upload::TYPE_INVOICE)) {
|
||||
$this->entityManager->remove($existingUpload);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
namespace App\Controller\Teamer\Disposition;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\DispositionRepository;
|
||||
use App\Service\Season\SeasonAssignmentResolver;
|
||||
use App\Service\Teamer\UpcomingPlacementCollector;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -14,7 +15,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class UpcomingController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DispositionRepository $dispositionRepository,
|
||||
private readonly UpcomingPlacementCollector $upcomingPlacementCollector,
|
||||
private readonly SeasonAssignmentResolver $seasonAssignmentResolver,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
) {
|
||||
}
|
||||
@@ -27,24 +29,18 @@ class UpcomingController extends AbstractController
|
||||
$user = $this->getUser();
|
||||
$teamer = $user->getTeamer();
|
||||
|
||||
$query = $this
|
||||
->dispositionRepository
|
||||
->getUpcomingDispositionsByTeamerQuery($teamer)
|
||||
;
|
||||
|
||||
// an array rather than a query: dispositions and season assignments are merged in PHP
|
||||
$pagination = $this->paginator->paginate(
|
||||
$query,
|
||||
$this->upcomingPlacementCollector->collect($teamer),
|
||||
$request->query->getInt('page', 1),
|
||||
9,
|
||||
[
|
||||
'defaultSortFieldName' => 'destination.dateFrom',
|
||||
'defaultSortDirection' => 'asc',
|
||||
]
|
||||
9
|
||||
);
|
||||
|
||||
return $this->render('teamer/disposition/upcoming.html.twig', [
|
||||
'teamer' => $teamer,
|
||||
'pagination' => $pagination,
|
||||
// the season page shows only this one, so only this one is linked
|
||||
'linkedSeasonAssignment' => $this->seasonAssignmentResolver->resolveFor($teamer),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
namespace App\Controller\Teamer;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Feedback;
|
||||
use App\Entity\News;
|
||||
use App\Entity\User;
|
||||
use App\Service\Season\SeasonAssignmentResolver;
|
||||
use App\Service\Teamer\UpcomingPlacementCollector;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -16,8 +19,11 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UpcomingPlacementCollector $upcomingPlacementCollector,
|
||||
private readonly SeasonAssignmentResolver $seasonAssignmentResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/teamer', name: 'app_teamer_index')]
|
||||
@@ -66,6 +72,18 @@ class IndexController extends AbstractController
|
||||
|
||||
$dispositionDocuments = $this->processDocuments($upcomingDispositions);
|
||||
|
||||
$pendingQuestionnaires = $this
|
||||
->entityManager
|
||||
->getRepository(Disposition::class)
|
||||
->findPendingQuestionnaireByTeamer(
|
||||
$teamer,
|
||||
QuestionnaireCatalog::windowStart((int) $this->getParameter('questionnaire_window_days')),
|
||||
)
|
||||
;
|
||||
|
||||
// the list merges in season assignments; the document notices above stay on dispositions
|
||||
$upcomingPlacements = array_slice($this->upcomingPlacementCollector->collect($teamer), 0, 5);
|
||||
|
||||
return $this->render('teamer/index.html.twig', [
|
||||
'news' => $news,
|
||||
'teamer' => $teamer,
|
||||
@@ -73,24 +91,36 @@ class IndexController extends AbstractController
|
||||
'recentFeedback' => $recentFeedback,
|
||||
'pendingApplications' => $pendingApplications,
|
||||
'currentDispositions' => $currentDispositions,
|
||||
'upcomingDispositions' => $upcomingDispositions,
|
||||
'upcomingPlacements' => $upcomingPlacements,
|
||||
// the season page shows only this one, so only this one is linked
|
||||
'linkedSeasonAssignment' => $this->seasonAssignmentResolver->resolveFor($teamer),
|
||||
'dispositionDocuments' => $dispositionDocuments,
|
||||
'pendingQuestionnaires' => $pendingQuestionnaires,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{disposition: Disposition, reason: 'contract_due'|'contract_rejected'|'invoice_due'}>
|
||||
*/
|
||||
private function processDocuments(array $upcomingDispositions): array
|
||||
{
|
||||
$contractUploadDeadlineDays = (int) $this->getParameter('contract_upload_deadline_days');
|
||||
$invoiceUploadDeadlineDays = (int) $this->getParameter('invoice_upload_deadline_days');
|
||||
$dispositions = [];
|
||||
|
||||
// The due checks need the configured deadlines, which the template cannot pass, so the
|
||||
// reason is resolved here. Order matters: the first matching reason is the one shown.
|
||||
foreach ($upcomingDispositions as $disposition) {
|
||||
/** @var Disposition $disposition */
|
||||
if ($disposition->isContractDue($contractUploadDeadlineDays)
|
||||
|| $disposition->isContractRejected()
|
||||
|| $disposition->isInvoiceDue($invoiceUploadDeadlineDays)
|
||||
) {
|
||||
$dispositions[] = $disposition;
|
||||
$reason = match (true) {
|
||||
$disposition->isContractDue($contractUploadDeadlineDays) => 'contract_due',
|
||||
$disposition->isContractRejected() => 'contract_rejected',
|
||||
$disposition->isInvoiceDue($invoiceUploadDeadlineDays) => 'invoice_due',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null !== $reason) {
|
||||
$dispositions[] = ['disposition' => $disposition, 'reason' => $reason];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace App\Controller\Teamer\Profile;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\ResponseParserException;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
@@ -59,12 +62,7 @@ class IndexController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$bpnPassword = $request->getSession()->get('bpn_password');
|
||||
$this->apiClient->updateProfile($user, $bpnPassword);
|
||||
$this->entityManager->flush();
|
||||
} catch (ApiClientException $e) {
|
||||
}
|
||||
$this->updateBusProProfile($user);
|
||||
|
||||
// Saving the full profile form is a stronger statement than ticking the
|
||||
// confirmation box, so it clears the verification check too.
|
||||
@@ -88,6 +86,44 @@ class IndexController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The local save does not depend on BusPro: a failed write is logged, and the teamer's data
|
||||
* is kept here either way.
|
||||
*/
|
||||
private function updateBusProProfile(User $user): void
|
||||
{
|
||||
$context = [
|
||||
'user_id' => $user->getId(),
|
||||
'bus_pro_address_id' => $user->getBusProAddressId(),
|
||||
'bus_pro_person_id' => $user->getBusProPersonId(),
|
||||
];
|
||||
|
||||
if (null === $user->getBusProAddressId() || null === $user->getBusProPersonId()) {
|
||||
$this->logger->warning('Skip BusPro profile update: user has no BusPro IDs', $context);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->updateProfile($user);
|
||||
} catch (ApiClientException|ResponseParserException $e) {
|
||||
$this->logger->error('BusPro profile update failed', $context + [
|
||||
'error_message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($response instanceof NotificationResponse && false === $response->isSuccessful()) {
|
||||
$this->logger->error('BusPro rejected profile update', $context + [
|
||||
'bpn_code' => $response->getCode(),
|
||||
'bpn_message' => $response->getMessage(),
|
||||
]);
|
||||
} elseif ($response instanceof ProfileUpdateResponse && false === $response->isUpdated()) {
|
||||
$this->logger->error('BusPro did not update profile', $context);
|
||||
}
|
||||
}
|
||||
|
||||
private function updatePhoto(User $user, UploadSessionDto $uploadSession): void
|
||||
{
|
||||
$teamer = $user->getTeamer();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Teamer\Questionnaire;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Entity\Disposition;
|
||||
use App\Repository\QuestionnaireResponseRepository;
|
||||
use App\Service\Questionnaire\DispositionContext;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The teamer's own submitted questionnaires for a placement, read-only, each rendered with the
|
||||
* definition version it was submitted under.
|
||||
*/
|
||||
class DetailController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QuestionnaireCatalog $catalog,
|
||||
private readonly QuestionnaireResponseRepository $responseRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/teamer/questionnaire/{uuid}/detail', name: 'app_teamer_questionnaire_detail')]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
#[IsGranted('QUESTIONNAIRE', subject: 'disposition')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
Disposition $disposition,
|
||||
): Response {
|
||||
$submitted = [];
|
||||
$open = false;
|
||||
$responses = $this->responseRepository->findByDisposition($disposition);
|
||||
|
||||
// what applies now, plus anything submitted under an earlier state of the assignment
|
||||
$keys = array_unique([...DispositionContext::questionnaires($disposition->getAssignment()), ...array_keys($responses)]);
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$response = $responses[$key] ?? null;
|
||||
|
||||
if (true === $response?->isSubmitted()) {
|
||||
$submitted[] = [
|
||||
'definition' => $this->catalog->get($key, $response->getVersion()),
|
||||
'response' => $response,
|
||||
];
|
||||
} else {
|
||||
$open = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $submitted) {
|
||||
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
|
||||
}
|
||||
|
||||
return $this->render('teamer/questionnaire/detail.html.twig', [
|
||||
'disposition' => $disposition,
|
||||
'submitted' => $submitted,
|
||||
'open' => $open,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Teamer\Questionnaire;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\QuestionnaireResponse;
|
||||
use App\Form\QuestionnaireType;
|
||||
use App\Repository\QuestionnaireResponseRepository;
|
||||
use App\Service\Questionnaire\DispositionContext;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The teamer's questionnaires after a placement on one page: the teamer feedback, and after a bus
|
||||
* escort the bus feedback too. Saved as drafts until submitted; one submit submits all of them,
|
||||
* each as a response of its own. Submitted ones are final and only shown read-only
|
||||
* (DetailController).
|
||||
*
|
||||
* Only the questionnaires not yet submitted are offered, so one that applies later - an escort
|
||||
* added after the teamer submitted - can still be filled in.
|
||||
*/
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QuestionnaireCatalog $catalog,
|
||||
private readonly QuestionnaireResponseRepository $responseRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/teamer/questionnaire/{uuid}', name: 'app_teamer_questionnaire_edit')]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
#[IsGranted('QUESTIONNAIRE', subject: 'disposition')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
Disposition $disposition,
|
||||
Request $request,
|
||||
): Response {
|
||||
$responses = $this->responseRepository->findByDisposition($disposition);
|
||||
$definitions = [];
|
||||
|
||||
foreach (DispositionContext::questionnaires($disposition->getAssignment()) as $key) {
|
||||
if (true !== ($responses[$key] ?? null)?->isSubmitted()) {
|
||||
$definitions[] = $this->catalog->get($key);
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $definitions) {
|
||||
return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]);
|
||||
}
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
$data[$definition->key] = ($responses[$definition->key] ?? null)?->getAnswers() ?? [];
|
||||
}
|
||||
|
||||
$form = $this->createForm(QuestionnaireType::class, $data, ['definitions' => $definitions]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$context = DispositionContext::build($disposition);
|
||||
$final = QuestionnaireType::isFinal($form);
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
$response = $responses[$definition->key] ?? null;
|
||||
|
||||
if (null === $response) {
|
||||
$response = QuestionnaireResponse::forDisposition($definition, $disposition);
|
||||
$this->entityManager->persist($response);
|
||||
$responses[$definition->key] = $response;
|
||||
}
|
||||
|
||||
$answers = $form->getData()[$definition->key] ?? [];
|
||||
|
||||
if (true === $final) {
|
||||
$response->submit($definition, $answers, $context, new \DateTimeImmutable());
|
||||
} else {
|
||||
$response->saveDraft($answers, $context);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
$this->logger->info($final ? 'Submit questionnaire' : 'Save questionnaire draft', [
|
||||
'questionnaire' => $definition->key,
|
||||
'questionnaire_response_id' => $responses[$definition->key]->getId(),
|
||||
'disposition_id' => $disposition->getId(),
|
||||
'teamer_id' => $disposition->getTeamer()->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (true === $final) {
|
||||
$this->addFlash('success', 'Danke! Dein Fragebogen wurde abgeschickt.');
|
||||
|
||||
return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Dein Entwurf wurde gespeichert.');
|
||||
|
||||
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
|
||||
}
|
||||
|
||||
$drafts = array_filter($responses, static fn (QuestionnaireResponse $response): bool => !$response->isSubmitted());
|
||||
|
||||
return $this->render('teamer/questionnaire/edit.html.twig', [
|
||||
'disposition' => $disposition,
|
||||
'form' => $form,
|
||||
'lastSaved' => [] === $drafts ? null : max(array_map(
|
||||
static fn (QuestionnaireResponse $response): \DateTimeInterface => $response->getUpdatedAt() ?? $response->getCreatedAt(),
|
||||
$drafts,
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ class ApplicationFormSubmissionMailer
|
||||
if (true !== $submission->isEu()) {
|
||||
$this->logger->info('Application form receipt mail skipped, not eligible', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
// The value, not the applicant's nationality: a flag the form set (§7).
|
||||
// The value, not the applicant's nationality: a flag the form set.
|
||||
'euFieldPresent' => null !== $submission->isEu(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -32,8 +32,7 @@ use Symfony\Component\Uid\Uuid;
|
||||
*
|
||||
* The typed columns are the mapped core agreed with the TYPO3 side. "details" is everything
|
||||
* else the visitor filled in, keyed by the German field label in the order the form asked -
|
||||
* display only, never a schema, never queried on (see
|
||||
* .ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.3).
|
||||
* display only, never a schema, never queried on.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ApplicationFormSubmissionRepository::class)]
|
||||
// What a list and a dedupe check ask for: the submissions of one form, newest first.
|
||||
@@ -129,7 +128,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
* of the payload - fromDto() must not touch them.
|
||||
*
|
||||
* The mails carry a time because a send time is a machine fact and stays one once the
|
||||
* notification mail of §6 is automated. A held call does not: the reviewer records it
|
||||
* notification mail is automated. A held call does not: the reviewer records it
|
||||
* afterwards and knows the day, so storing an invented time would only look precise.
|
||||
*/
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
@@ -175,7 +174,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
/**
|
||||
* Everything the visitor filled in beyond the mapped core, keyed by the German field label.
|
||||
* Display only: the labels are staff-written and change on a reword, so nothing may query
|
||||
* this, build a column from it or migrate old rows to a new spelling (§3.3).
|
||||
* this, build a column from it or migrate old rows to a new spelling.
|
||||
*
|
||||
* The key order is whatever the engine hands back and is not part of what this carries: on
|
||||
* MariaDB (dev and production) JSON is an alias for LONGTEXT and the document survives
|
||||
@@ -189,7 +188,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
|
||||
/**
|
||||
* The request body as received. Mapped keys this application does not know yet are ignored
|
||||
* on the way in (§3.2) and the TYPO3 side widens a form's mapping without a release here,
|
||||
* on the way in and the TYPO3 side widens a form's mapping without a release here,
|
||||
* so without this copy those answers would be lost for good instead of backfillable.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
@@ -214,7 +213,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
*
|
||||
* The raw payload is passed in separately because the DTO no longer has it: the resolver
|
||||
* drops every key the DTO has no property for, and those are the mapped fields the TYPO3
|
||||
* side has added and this application does not know yet (§3.2). Keeping the body is what
|
||||
* side has added and this application does not know yet. Keeping the body is what
|
||||
* makes them backfillable instead of lost.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
|
||||
@@ -256,13 +256,22 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the teamer escorts the bus: the "Busbegleitung ab" pickup is set. 0 is stored for
|
||||
* "Keine Busbegleitung" as well as null, so this is not a plain null check.
|
||||
*/
|
||||
public function hasBusEscort(): bool
|
||||
{
|
||||
return 0 !== (int) $this->pickup;
|
||||
}
|
||||
|
||||
public function getEffectivePickupDate(): ?\DateTimeImmutable
|
||||
{
|
||||
if (null !== $this->pickupDate) {
|
||||
return $this->pickupDate;
|
||||
}
|
||||
|
||||
if (0 !== (int) $this->pickup && null !== $destination = $this->getDestination()) {
|
||||
if ($this->hasBusEscort() && null !== $destination = $this->getDestination()) {
|
||||
return $destination
|
||||
->getDateFrom()
|
||||
->modify('-1 day')
|
||||
@@ -472,6 +481,78 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The lowest place number no active placement holds - the one a new placement takes.
|
||||
*/
|
||||
public function getNextFreeSlot(): int
|
||||
{
|
||||
return $this->nextFreeSlotIn(array_flip($this->getHeldSlots()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The places the n8n feed reports, as place number => the active placement filling it, or
|
||||
* null for an open place. A place is a row there, not a person, so a called-off teamer's place
|
||||
* is open again under the same number rather than gone.
|
||||
*
|
||||
* Every held place is listed, topped up with the lowest free numbers to availableDispositions.
|
||||
* Lowering the count while a high number is still held therefore drops an open low number, not
|
||||
* the held one; a held place beyond the count is still listed, it has a person on it.
|
||||
*
|
||||
* @return array<int, Disposition|null>
|
||||
*/
|
||||
public function getPlaces(): array
|
||||
{
|
||||
$places = [];
|
||||
$unnumbered = [];
|
||||
|
||||
foreach ($this->getActiveDispositions() as $disposition) {
|
||||
if (null === $slot = $disposition->getSlot()) {
|
||||
$unnumbered[] = $disposition;
|
||||
} else {
|
||||
$places[$slot] = $disposition;
|
||||
}
|
||||
}
|
||||
|
||||
// Without a number only when created outside DisposeController; such a placement takes
|
||||
// the next free number after the numbered ones, so that nobody drops out of the feed.
|
||||
foreach ($unnumbered as $disposition) {
|
||||
$places[$this->nextFreeSlotIn($places)] = $disposition;
|
||||
}
|
||||
|
||||
while (count($places) < (int) $this->availableDispositions) {
|
||||
$places[$this->nextFreeSlotIn($places)] = null;
|
||||
}
|
||||
|
||||
ksort($places);
|
||||
|
||||
return $places;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function getHeldSlots(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->getActiveDispositions()->map(fn (Disposition $disposition) => $disposition->getSlot())->toArray(),
|
||||
fn (?int $slot) => null !== $slot,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $places
|
||||
*/
|
||||
private function nextFreeSlotIn(array $places): int
|
||||
{
|
||||
$slot = 1;
|
||||
|
||||
while (array_key_exists($slot, $places)) {
|
||||
++$slot;
|
||||
}
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
public function getConfirmedDispositions(): Collection
|
||||
{
|
||||
return $this->dispositions->filter(function (Disposition $disposition) {
|
||||
|
||||
@@ -55,6 +55,20 @@ class Destination implements TimestampableEntityInterface, SoftDeletableEntityIn
|
||||
#[ORM\Column(length: 2, nullable: true)]
|
||||
private ?string $country = null;
|
||||
|
||||
/**
|
||||
* The travel this date belongs to, as MyE&P reports it. Not the date code minus its date
|
||||
* suffix - see App\MyEp\Import\DestinationRow::readProductCode().
|
||||
*/
|
||||
#[ORM\Column(length: 32, nullable: true)]
|
||||
private ?string $productCode = null;
|
||||
|
||||
/**
|
||||
* When app:myep-import last saw this row. Not updatedAt: a destination is editable in the
|
||||
* UI, so updatedAt cannot tell a row the latest import touched from one it no longer reports.
|
||||
*/
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $importedAt = null;
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf(
|
||||
@@ -234,4 +248,28 @@ class Destination implements TimestampableEntityInterface, SoftDeletableEntityIn
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProductCode(): ?string
|
||||
{
|
||||
return $this->productCode;
|
||||
}
|
||||
|
||||
public function setProductCode(?string $productCode): static
|
||||
{
|
||||
$this->productCode = $productCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getImportedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->importedAt;
|
||||
}
|
||||
|
||||
public function setImportedAt(?\DateTimeImmutable $importedAt): static
|
||||
{
|
||||
$this->importedAt = $importedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,24 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
private bool $syncedWithBusProNet = false;
|
||||
|
||||
/**
|
||||
* The office no longer expects a feedback for this placement, so nothing chases one: not the
|
||||
* dashboards' overdue lists, not the house-manager list, not the reminder mail. It still
|
||||
* counts as missing in the statistics.
|
||||
*/
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
|
||||
private bool $feedbackWaived = false;
|
||||
|
||||
/**
|
||||
* The numbered place on the assignment this placement fills - the n8n feed's "platz_id" is
|
||||
* "<assignment id>-<slot>", and n8n hangs the Wechselpass off it. Taken once, here at creation,
|
||||
* and never renumbered: a replacement takes the number the called-off teamer freed, whoever
|
||||
* stays keeps theirs. A called-off placement keeps its number for the record but no longer
|
||||
* holds it (Assignment::getPlaces()).
|
||||
*/
|
||||
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||
private ?int $slot = null;
|
||||
|
||||
public function __construct(Application $application)
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
@@ -81,6 +99,7 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
? static::STATUS_CONFIRMED
|
||||
: static::STATUS_NEW;
|
||||
$this->assignment = $application->getAssignment();
|
||||
$this->slot = $this->assignment?->getNextFreeSlot();
|
||||
$this->teamer = $application->getTeamer();
|
||||
$this->pickup = $application->getPickup();
|
||||
$this->specialAgreements = $application->getRequests();
|
||||
@@ -353,4 +372,28 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isFeedbackWaived(): bool
|
||||
{
|
||||
return $this->feedbackWaived;
|
||||
}
|
||||
|
||||
public function setFeedbackWaived(bool $feedbackWaived): static
|
||||
{
|
||||
$this->feedbackWaived = $feedbackWaived;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSlot(): ?int
|
||||
{
|
||||
return $this->slot;
|
||||
}
|
||||
|
||||
public function setSlot(?int $slot): static
|
||||
{
|
||||
$this->slot = $slot;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\BlameableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Enum\QuestionnaireResponseStatus;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use App\Repository\QuestionnaireResponseRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* One respondent's answers to one questionnaire (config/questionnaires/), a draft until submitted.
|
||||
*
|
||||
* Answers are keyed by question key and only mean something together with the definition of
|
||||
* $questionnaire in $version. $context holds what the subject already knew - for a disposition,
|
||||
* who, where, when and the escort pickup - so those questions are not asked and the response stays
|
||||
* readable when the assignment is edited later.
|
||||
*
|
||||
* Its subject is a Disposition today. A questionnaire about something else gets a nullable foreign
|
||||
* key of its own beside $disposition, not a generic subject column.
|
||||
*
|
||||
* Not soft-deletable: nothing in the UI deletes a response, and it goes with its disposition.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: QuestionnaireResponseRepository::class)]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\UniqueConstraint(name: 'questionnaire_disposition', columns: ['questionnaire', 'disposition_id'])]
|
||||
class QuestionnaireResponse implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(length: 64)]
|
||||
private string $questionnaire;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $version;
|
||||
|
||||
#[ORM\Column(length: 16, enumType: QuestionnaireResponseStatus::class)]
|
||||
private QuestionnaireResponseStatus $status = QuestionnaireResponseStatus::DRAFT;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $submittedAt = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $answers = [];
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $context = [];
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE')]
|
||||
private ?Disposition $disposition = null;
|
||||
|
||||
private function __construct(Definition $definition)
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
$this->questionnaire = $definition->key;
|
||||
$this->version = $definition->version;
|
||||
}
|
||||
|
||||
public static function forDisposition(Definition $definition, Disposition $disposition): static
|
||||
{
|
||||
$instance = new static($definition);
|
||||
$instance->disposition = $disposition;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps every answer, including those to questions that are hidden right now: switching an
|
||||
* answer back restores the follow-up already written.
|
||||
*
|
||||
* @param array<string, mixed> $answers
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function saveDraft(array $answers, array $context): static
|
||||
{
|
||||
$this->assertDraft();
|
||||
|
||||
$this->answers = $answers;
|
||||
$this->context = $context;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final. Keeps only the answers to questions that are shown, and records the definition version
|
||||
* they were given under - the draft may have been started under an older one.
|
||||
*
|
||||
* @param array<string, mixed> $answers
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function submit(Definition $definition, array $answers, array $context, \DateTimeImmutable $now): static
|
||||
{
|
||||
$this->assertDraft();
|
||||
|
||||
if ($definition->key !== $this->questionnaire) {
|
||||
throw new \LogicException(sprintf('Response to "%s" cannot be submitted with "%s"', $this->questionnaire, $definition->key));
|
||||
}
|
||||
|
||||
$this->answers = $definition->withoutHiddenAnswers($answers);
|
||||
$this->context = $context;
|
||||
$this->version = $definition->version;
|
||||
$this->status = QuestionnaireResponseStatus::SUBMITTED;
|
||||
$this->submittedAt = $now;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSubmitted(): bool
|
||||
{
|
||||
return QuestionnaireResponseStatus::SUBMITTED === $this->status;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUuid(): string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getQuestionnaire(): string
|
||||
{
|
||||
return $this->questionnaire;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function getStatus(): QuestionnaireResponseStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function getSubmittedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->submittedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getAnswers(): array
|
||||
{
|
||||
return $this->answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getContext(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
|
||||
public function getDisposition(): ?Disposition
|
||||
{
|
||||
return $this->disposition;
|
||||
}
|
||||
|
||||
private function assertDraft(): void
|
||||
{
|
||||
if ($this->isSubmitted()) {
|
||||
throw new \LogicException(sprintf('Questionnaire response %s is already submitted', $this->uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,30 @@ class SeasonAssignment implements BlameableEntityInterface, TimestampableEntityI
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies both deadline overrides of $source onto every other entry that is not ticked off,
|
||||
* empty ones included - an empty override resets the entry to its computed deadline. Entries
|
||||
* already done keep theirs: their deadline is history by then.
|
||||
*
|
||||
* @return int the number of entries changed, $source not counted
|
||||
*/
|
||||
public function applyDeadlinesToOpenTasks(SeasonAssignmentTask $source): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->tasks as $task) {
|
||||
if ($task === $source || $task->isDone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$task->setDeadlineOverride1($source->getDeadlineOverride1());
|
||||
$task->setDeadlineOverride2($source->getDeadlineOverride2());
|
||||
++$count;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Upload>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\BlameableEntity;
|
||||
use App\Entity\Traits\SoftDeletableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Repository\StaffingRuleRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* One row of the stakeholders' staffing matrix: how every date of a product is staffed.
|
||||
* App\Service\Assignment\AssignmentPlanner turns it into draft assignments.
|
||||
*
|
||||
* Unique among live rows only, so no database index: a soft-deleted row keeps its code.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: StaffingRuleRepository::class)]
|
||||
#[UniqueEntity(
|
||||
fields: ['productCode'],
|
||||
message: 'Für dieses Produkt gibt es bereits eine Zeile',
|
||||
repositoryMethod: 'findLiveByProductCode'
|
||||
)]
|
||||
class StaffingRule implements BlameableEntityInterface, TimestampableEntityInterface, SoftDeletableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
use SoftDeletableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
/** The travel code MyE&P reports as productCode, not a date-code prefix. */
|
||||
#[ORM\Column(length: 32)]
|
||||
#[Assert\NotBlank(message: 'Bitte gib den Produktcode an')]
|
||||
#[Assert\Length(max: 32, maxMessage: 'Der Produktcode darf höchstens {{ limit }} Zeichen lang sein')]
|
||||
private ?string $productCode = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Assert\NotNull(message: 'Bitte gib die Anzahl der Reiseleitungen an')]
|
||||
#[Assert\PositiveOrZero(message: 'Die Anzahl darf nicht negativ sein')]
|
||||
private ?int $guides = 1;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $skiBeginner = false;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $skiAdvanced = false;
|
||||
|
||||
/** A Reiseleitung takes over the beginners' course (Reise- und Kursleitung), one place. */
|
||||
#[ORM\Column]
|
||||
private bool $splitBeginner = false;
|
||||
|
||||
/** A Reiseleitung takes over the advanced course (Reise- und Kursleitung), one place. */
|
||||
#[ORM\Column]
|
||||
private bool $splitAdvanced = false;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $guiding = false;
|
||||
|
||||
/** Previewed either way; only enabled rows generate. */
|
||||
#[ORM\Column]
|
||||
private bool $enabled = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateCombination(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (true === $this->splitBeginner && false === $this->skiBeginner) {
|
||||
$context
|
||||
->buildViolation('Die Reiseleitung kann nur einen Kurs übernehmen, der angeboten wird')
|
||||
->atPath('splitBeginner')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (true === $this->splitAdvanced && false === $this->skiAdvanced) {
|
||||
$context
|
||||
->buildViolation('Die Reiseleitung kann nur einen Kurs übernehmen, der angeboten wird')
|
||||
->atPath('splitAdvanced')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if ($this->getSplitLevelCount() > (int) $this->guides) {
|
||||
$context
|
||||
->buildViolation('Jeder übernommene Kurs braucht eine eigene Reiseleitung')
|
||||
->atPath('guides')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (true === $this->guiding && 0 === (int) $this->guides) {
|
||||
$context
|
||||
->buildViolation('Guiding braucht mindestens eine Reiseleitung')
|
||||
->atPath('guiding')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
public function getSplitLevelCount(): int
|
||||
{
|
||||
return (int) $this->splitBeginner + (int) $this->splitAdvanced;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUuid(): string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getProductCode(): ?string
|
||||
{
|
||||
return $this->productCode;
|
||||
}
|
||||
|
||||
public function setProductCode(?string $productCode): static
|
||||
{
|
||||
$productCode = strtoupper(trim((string) $productCode));
|
||||
$this->productCode = '' === $productCode ? null : $productCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGuides(): ?int
|
||||
{
|
||||
return $this->guides;
|
||||
}
|
||||
|
||||
public function setGuides(?int $guides): static
|
||||
{
|
||||
$this->guides = $guides;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSkiBeginner(): bool
|
||||
{
|
||||
return $this->skiBeginner;
|
||||
}
|
||||
|
||||
public function setSkiBeginner(bool $skiBeginner): static
|
||||
{
|
||||
$this->skiBeginner = $skiBeginner;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSkiAdvanced(): bool
|
||||
{
|
||||
return $this->skiAdvanced;
|
||||
}
|
||||
|
||||
public function setSkiAdvanced(bool $skiAdvanced): static
|
||||
{
|
||||
$this->skiAdvanced = $skiAdvanced;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSplitBeginner(): bool
|
||||
{
|
||||
return $this->splitBeginner;
|
||||
}
|
||||
|
||||
public function setSplitBeginner(bool $splitBeginner): static
|
||||
{
|
||||
$this->splitBeginner = $splitBeginner;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSplitAdvanced(): bool
|
||||
{
|
||||
return $this->splitAdvanced;
|
||||
}
|
||||
|
||||
public function setSplitAdvanced(bool $splitAdvanced): static
|
||||
{
|
||||
$this->splitAdvanced = $splitAdvanced;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isGuiding(): bool
|
||||
{
|
||||
return $this->guiding;
|
||||
}
|
||||
|
||||
public function setGuiding(bool $guiding): static
|
||||
{
|
||||
$this->guiding = $guiding;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $enabled): static
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* How a single-choice question is rendered: a radio list or a select.
|
||||
*/
|
||||
enum ChoiceWidget: string
|
||||
{
|
||||
case RADIO = 'radio';
|
||||
case DROPDOWN = 'dropdown';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* How a show_if condition compares the controlling question's answer with its value.
|
||||
*/
|
||||
enum ConditionOperator: string
|
||||
{
|
||||
case EQUALS = 'equals';
|
||||
case IN = 'in';
|
||||
case LTE = 'lte';
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
|
||||
/**
|
||||
* The status of a place in the n8n assignments feed. The values are the feed's contract.
|
||||
*/
|
||||
enum PlaceStatus: string
|
||||
{
|
||||
case OPEN = 'offen';
|
||||
case REQUESTED = 'angefragt';
|
||||
case CONTRACT = 'vertrag';
|
||||
case FIXED = 'fix';
|
||||
|
||||
/**
|
||||
* A called-off placement holds no place, so it never reaches this - its place is open.
|
||||
*/
|
||||
public static function fromDisposition(?Disposition $disposition): self
|
||||
{
|
||||
return match ($disposition?->getStatus()) {
|
||||
null, Disposition::STATUS_CALLED_OFF => self::OPEN,
|
||||
Disposition::STATUS_NEW => self::REQUESTED,
|
||||
Disposition::STATUS_CHECKING_CONTRACT => self::CONTRACT,
|
||||
default => self::FIXED,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* The kinds of question a questionnaire definition may use. Each maps to exactly one form field
|
||||
* in QuestionnaireType; adding one means a case here and a line there, nothing more.
|
||||
*/
|
||||
enum QuestionType: string
|
||||
{
|
||||
case TEXT = 'text';
|
||||
case TEXTAREA = 'textarea';
|
||||
case CHOICE = 'choice';
|
||||
case MULTI = 'multi';
|
||||
case DATE = 'date';
|
||||
case RATING = 'rating';
|
||||
|
||||
public function hasOptions(): bool
|
||||
{
|
||||
return self::CHOICE === $this || self::MULTI === $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum QuestionnaireResponseStatus: string
|
||||
{
|
||||
case DRAFT = 'draft';
|
||||
case SUBMITTED = 'submitted';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => 'Entwurf',
|
||||
self::SUBMITTED => 'Abgeschickt',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* A fee the staffing matrix puts on a generated assignment. Each case maps to one Fee row
|
||||
* through the auto_assignment_fees parameter; the values are its keys.
|
||||
*/
|
||||
enum StaffingFee: string
|
||||
{
|
||||
case GUIDING = 'guiding';
|
||||
case COURSE = 'course';
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* A role the staffing matrix generates an assignment for. Each case maps to one job profile
|
||||
* through the auto_assignment_job_profiles parameter; the values are its keys.
|
||||
*/
|
||||
enum StaffingRole: string
|
||||
{
|
||||
case GUIDE = 'guide';
|
||||
case SKI_BEGINNER = 'ski_beginner';
|
||||
case SKI_ADVANCED = 'ski_advanced';
|
||||
case GUIDE_SKI_BEGINNER = 'guide_ski_beginner';
|
||||
case GUIDE_SKI_ADVANCED = 'guide_ski_advanced';
|
||||
|
||||
/** A Reiseleitung, alone or combined with a course: the roles that can guide and escort the bus. */
|
||||
public function isGuide(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::GUIDE, self::GUIDE_SKI_BEGINNER, self::GUIDE_SKI_ADVANCED => true,
|
||||
self::SKI_BEGINNER, self::SKI_ADVANCED => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function isCourse(): bool
|
||||
{
|
||||
return self::GUIDE !== $this;
|
||||
}
|
||||
}
|
||||
@@ -5,31 +5,70 @@ namespace App\EventListener;
|
||||
use App\Entity\Upload;
|
||||
use App\Service\Upload\UploadHandler;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\Event\PreRemoveEventArgs;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Event\PostFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
||||
use Symfony\Contracts\Service\ResetInterface;
|
||||
|
||||
#[AsDoctrineListener(event: Events::preRemove)]
|
||||
class DeleteUploadListener
|
||||
/**
|
||||
* Removes the files of deleted uploads from disk, once the deletion has been committed.
|
||||
*
|
||||
* Doctrine dispatches preRemove inside EntityManager::remove(), not at flush. Deleting the file
|
||||
* there meant that a request which removed the old upload and then failed before its flush -
|
||||
* a replacement that could not be built - lost the file while the row survived. The files are
|
||||
* therefore collected at onFlush and only deleted at postFlush, which is reached only after the
|
||||
* transaction has committed. A flush that throws never gets there, and the next onFlush drops
|
||||
* what it collected.
|
||||
*/
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
#[AsDoctrineListener(event: Events::postFlush)]
|
||||
class DeleteUploadListener implements ResetInterface
|
||||
{
|
||||
/**
|
||||
* @var array<int, Upload>
|
||||
*/
|
||||
private array $pending = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly UploadHandler $uploadHandler,
|
||||
private readonly CacheManager $cacheManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function preRemove(PreRemoveEventArgs $eventArgs): void
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$object = $eventArgs->getObject();
|
||||
$this->pending = [];
|
||||
|
||||
if (!$object instanceof Upload) {
|
||||
// Includes uploads removed by cascade, e.g. the documents of a deleted disposition.
|
||||
foreach ($args->getObjectManager()->getUnitOfWork()->getScheduledEntityDeletions() as $entity) {
|
||||
if ($entity instanceof Upload) {
|
||||
$this->pending[] = $entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function postFlush(PostFlushEventArgs $args): void
|
||||
{
|
||||
if (0 === count($this->pending)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove uploaded original
|
||||
$this->uploadHandler->removeUploadedFile($object);
|
||||
// Taken and cleared up front, in case anything downstream flushes again.
|
||||
$pending = $this->pending;
|
||||
$this->pending = [];
|
||||
|
||||
// Remove potential variants from public folders
|
||||
$this->cacheManager->remove($object->getFilename());
|
||||
foreach ($pending as $upload) {
|
||||
// Remove uploaded original
|
||||
$this->uploadHandler->removeUploadedFile($upload);
|
||||
|
||||
// Remove potential variants from public folders
|
||||
$this->cacheManager->remove($upload->getFilename());
|
||||
}
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->pending = [];
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ namespace App\Form;
|
||||
use App\Entity\Feedback;
|
||||
use App\Entity\FeedbackSet;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -24,20 +23,16 @@ abstract class AbstractFeedbackType extends AbstractType
|
||||
'label' => false,
|
||||
'feedback_set' => $feedbackSet,
|
||||
])
|
||||
->add('comment', TextareaType::class, [
|
||||
->add('comment', AutosizeTextareaType::class, [
|
||||
'label' => 'Kommentar (öffentlich)',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 5,
|
||||
],
|
||||
'min_rows' => 5,
|
||||
'help' => 'Bitte formuliere deinen Kommentar so, dass er an das Team weitergegeben werden kann',
|
||||
])
|
||||
->add('commentInternal', TextareaType::class, [
|
||||
->add('commentInternal', AutosizeTextareaType::class, [
|
||||
'label' => 'Kommentar (intern)',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 5,
|
||||
],
|
||||
'min_rows' => 5,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use App\Model\ApplicationStatusDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -24,12 +23,10 @@ class ApplicationStatusType extends AbstractType
|
||||
'neu' => Application::STATUS_NEW,
|
||||
],
|
||||
])
|
||||
->add('comment', TextareaType::class, [
|
||||
->add('comment', AutosizeTextareaType::class, [
|
||||
'label' => 'Kommentar/Begründung',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 3,
|
||||
],
|
||||
'min_rows' => 3,
|
||||
])
|
||||
->add('commentVisible', CheckboxType::class, [
|
||||
'label' => 'Kommentar/Begründung sichtbar für Teamer:in',
|
||||
|
||||
@@ -15,7 +15,6 @@ use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
@@ -73,23 +72,19 @@ class AssignmentType extends AbstractType
|
||||
->where('job_profile.deletedAt IS NULL');
|
||||
},
|
||||
])
|
||||
->add('jobProfileInfo', TextareaType::class, [
|
||||
->add('jobProfileInfo', AutosizeTextareaType::class, [
|
||||
'label' => 'Zusatzinfos',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 3,
|
||||
],
|
||||
'min_rows' => 3,
|
||||
])
|
||||
// ->add('pickupDate', DatepickerType::class, [
|
||||
// 'label' => 'abweichender Zustieg bei Busbegleitung',
|
||||
// 'required' => false,
|
||||
// ])
|
||||
->add('benefits', TextareaType::class, [
|
||||
->add('benefits', AutosizeTextareaType::class, [
|
||||
'label' => 'Benefits',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 5,
|
||||
],
|
||||
'min_rows' => 5,
|
||||
])
|
||||
->add('fees', EntityType::class, [
|
||||
'label' => 'Honorar(e)',
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Entity\Upload;
|
||||
use App\Model\DocumentCheckDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
@@ -21,12 +20,10 @@ class DocumentCheckType extends AbstractType
|
||||
'label' => 'neuer Status',
|
||||
'choices' => $options['status_choices'][$options['document_type']],
|
||||
])
|
||||
->add('comment', TextareaType::class, [
|
||||
->add('comment', AutosizeTextareaType::class, [
|
||||
'label' => 'Kommentar/Begründung',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 3,
|
||||
],
|
||||
'min_rows' => 3,
|
||||
])
|
||||
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
|
||||
/** @var DocumentCheckDto $data */
|
||||
@@ -34,7 +31,7 @@ class DocumentCheckType extends AbstractType
|
||||
$upload = $data->getUpload();
|
||||
|
||||
if (Upload::TYPE_INVOICE === $upload->getType()) {
|
||||
$event->getForm()->add('specialAgreements', TextareaType::class, [
|
||||
$event->getForm()->add('specialAgreements', AutosizeTextareaType::class, [
|
||||
'label' => 'zusätzliche Absprache',
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Entity\Upload;
|
||||
use App\Model\DriverLicenseCheckDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -23,12 +22,10 @@ class DriverLicenseCheckType extends AbstractType
|
||||
'abgelehnt' => Upload::STATUS_REJECTED,
|
||||
],
|
||||
])
|
||||
->add('comment', TextareaType::class, [
|
||||
->add('comment', AutosizeTextareaType::class, [
|
||||
'label' => 'Kommentar/Begründung',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 3,
|
||||
],
|
||||
'min_rows' => 3,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Form;
|
||||
|
||||
use App\Entity\Faq;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -17,8 +16,9 @@ class FaqType extends AbstractType
|
||||
->add('question', TextType::class, [
|
||||
'label' => 'Frage',
|
||||
])
|
||||
->add('answer', TextareaType::class, [
|
||||
->add('answer', AutosizeTextareaType::class, [
|
||||
'label' => 'Antwort',
|
||||
'min_rows' => 5,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Form;
|
||||
|
||||
use App\Entity\Feedback;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -13,19 +12,15 @@ class FeedbackApproveType extends AbstractType
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('comment', TextareaType::class, [
|
||||
->add('comment', AutosizeTextareaType::class, [
|
||||
'label' => 'Kommentar (öffentlich)',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 5,
|
||||
],
|
||||
'min_rows' => 5,
|
||||
])
|
||||
->add('commentInternal', TextareaType::class, [
|
||||
->add('commentInternal', AutosizeTextareaType::class, [
|
||||
'label' => 'Kommentar (intern)',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 5,
|
||||
],
|
||||
'min_rows' => 5,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Form;
|
||||
use App\Entity\News;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -22,11 +21,9 @@ class NewsType extends AbstractType
|
||||
'label.news.status.2' => News::STATUS_IMPORTANT,
|
||||
],
|
||||
])
|
||||
->add('message', TextareaType::class, [
|
||||
->add('message', AutosizeTextareaType::class, [
|
||||
'label' => 'Nachricht',
|
||||
'attr' => [
|
||||
'rows' => 5,
|
||||
],
|
||||
'min_rows' => 5,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Model\QuestionnaireResponseFilterDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class QuestionnaireResponseFilterType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('teamer', AutocompleteEntityType::class, [
|
||||
'label' => 'Teamer',
|
||||
'required' => false,
|
||||
'class' => Teamer::class,
|
||||
'label_property' => 'name',
|
||||
'label_function' => fn (Teamer $teamer) => (string) $teamer,
|
||||
'endpoint_route' => 'app_admin_autocomplete_teamer',
|
||||
])
|
||||
->add('hotel', TextType::class, [
|
||||
'label' => 'Haus',
|
||||
'required' => false,
|
||||
])
|
||||
->add('dateFrom', DatepickerType::class, [
|
||||
'label' => 'Einsatzende von',
|
||||
'required' => false,
|
||||
])
|
||||
->add('dateTo', DatepickerType::class, [
|
||||
'label' => 'Einsatzende bis',
|
||||
'required' => false,
|
||||
])
|
||||
->add('apply', SubmitType::class, [
|
||||
'label' => 'filtern',
|
||||
])
|
||||
->add('reset', SubmitType::class, [
|
||||
'label' => 'reset',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => QuestionnaireResponseFilterDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\QuestionType;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use App\Model\Questionnaire\Option;
|
||||
use App\Model\Questionnaire\Question;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\ClickableInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Builds one form from one or more questionnaire definitions - a teamer after a bus escort answers
|
||||
* teamer_feedback and bus_feedback on one page. The data is keyed by questionnaire key, each
|
||||
* holding that questionnaire's flat answers keyed by question key: every questionnaire is a child
|
||||
* of its own, and each of its sections a child with inherit_data, which groups the fields on screen
|
||||
* without nesting the answers.
|
||||
*
|
||||
* Two buttons: "draft" saves whatever is filled in, "submit" also demands every required question
|
||||
* that is shown. No field is HTML-required, or the browser would refuse to send a half-filled
|
||||
* draft. Whether a question is shown is Definition::isVisible(), the same rule the
|
||||
* questionnaire Stimulus controller applies on screen via the row's data-show-if.
|
||||
*/
|
||||
class QuestionnaireType extends AbstractType
|
||||
{
|
||||
public const string DRAFT = 'draft';
|
||||
public const string SUBMIT = 'submit';
|
||||
|
||||
private const int RATING_MAX = 5;
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
/** @var list<Definition> $definitions */
|
||||
$definitions = $options['definitions'];
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
$questionnaireBuilder = $builder->create($definition->key, FormType::class, [
|
||||
'label' => $definition->title,
|
||||
'help' => $definition->description,
|
||||
'empty_data' => [],
|
||||
'block_prefix' => 'questionnaire_part',
|
||||
]);
|
||||
|
||||
foreach ($definition->sections as $section) {
|
||||
$sectionBuilder = $builder->create($section->key, FormType::class, [
|
||||
'inherit_data' => true,
|
||||
'label' => $section->title ?? false,
|
||||
'block_prefix' => 'questionnaire_section',
|
||||
]);
|
||||
|
||||
foreach ($section->questions as $question) {
|
||||
$sectionBuilder->add($question->key, ...$this->field($definition, $question));
|
||||
}
|
||||
|
||||
$questionnaireBuilder->add($sectionBuilder);
|
||||
}
|
||||
|
||||
$builder->add($questionnaireBuilder);
|
||||
}
|
||||
|
||||
$builder
|
||||
->add(self::DRAFT, SubmitType::class, [
|
||||
'label' => 'Entwurf speichern',
|
||||
])
|
||||
->add(self::SUBMIT, SubmitType::class, [
|
||||
'label' => 'Absenden',
|
||||
])
|
||||
->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($definitions): void {
|
||||
$form = $event->getForm();
|
||||
|
||||
if (!$this->isFinal($form)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
$answers = $form->getData()[$definition->key] ?? [];
|
||||
|
||||
foreach ($definition->sections as $section) {
|
||||
foreach ($section->questions as $question) {
|
||||
if ($question->required
|
||||
&& $definition->isVisible($question, $answers)
|
||||
&& $this->isEmpty($answers[$question->key] ?? null)
|
||||
) {
|
||||
$form->get($definition->key)->get($section->key)->get($question->key)->addError(
|
||||
new FormError('Bitte beantworte diese Frage.')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver
|
||||
->setRequired('definitions')
|
||||
->setAllowedTypes('definitions', Definition::class.'[]')
|
||||
->setAllowedValues('definitions', static fn (array $definitions): bool => [] !== $definitions
|
||||
&& count($definitions) === count(array_unique(array_map(static fn (Definition $definition): string => $definition->key, $definitions))))
|
||||
->setDefaults([
|
||||
'data_class' => null,
|
||||
'empty_data' => [],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the form was sent with the submit button rather than as a draft. A form sent without
|
||||
* any button (Enter in a text field) counts as a draft: losing nothing beats submitting early.
|
||||
*/
|
||||
public static function isFinal(FormInterface $form): bool
|
||||
{
|
||||
$button = $form->get(self::SUBMIT);
|
||||
|
||||
return $button instanceof ClickableInterface && $button->isClicked();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: class-string, 1: array<string, mixed>}
|
||||
*/
|
||||
private function field(Definition $definition, Question $question): array
|
||||
{
|
||||
$common = [
|
||||
'label' => $question->label,
|
||||
'help' => $question->help,
|
||||
'required' => false,
|
||||
// read by assets/controllers/questionnaire_controller.js; prefixed with the questionnaire,
|
||||
// since question keys are only unique within one
|
||||
'row_attr' => ['data-question-key' => $definition->key.'.'.$question->key] + (null === $question->showIf ? [] : [
|
||||
'data-show-if' => json_encode([
|
||||
'question' => $definition->key.'.'.$question->showIf->question,
|
||||
'operator' => $question->showIf->operator->value,
|
||||
'value' => $question->showIf->value,
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]),
|
||||
];
|
||||
|
||||
return match ($question->type) {
|
||||
QuestionType::TEXT => [TextType::class, $common],
|
||||
QuestionType::TEXTAREA => [AutosizeTextareaType::class, $common + ['min_rows' => 3]],
|
||||
QuestionType::CHOICE => [ChoiceType::class, $common + $this->choices($question) + (ChoiceWidget::RADIO === $question->widget ? [
|
||||
'expanded' => true,
|
||||
// a radio list needs no "none" entry
|
||||
'placeholder' => false,
|
||||
'block_prefix' => 'questionnaire_choice',
|
||||
] : [
|
||||
'expanded' => false,
|
||||
// a select needs an empty first line, or the first option would be preselected
|
||||
'placeholder' => 'Bitte wählen',
|
||||
])],
|
||||
QuestionType::MULTI => [ChoiceType::class, $common + $this->choices($question) + [
|
||||
'expanded' => true,
|
||||
'multiple' => true,
|
||||
'block_prefix' => 'questionnaire_choice',
|
||||
]],
|
||||
QuestionType::DATE => [DatepickerType::class, $common + [
|
||||
'input' => 'string',
|
||||
'input_format' => 'Y-m-d',
|
||||
]],
|
||||
QuestionType::RATING => [ChoiceType::class, $common + [
|
||||
'choices' => array_combine(range(1, self::RATING_MAX), range(1, self::RATING_MAX)),
|
||||
'expanded' => true,
|
||||
'placeholder' => false,
|
||||
'block_prefix' => 'questionnaire_rating',
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The option keys as choices, labelled through a callback: keyed by label, two options that
|
||||
* share one would silently collapse into a single choice.
|
||||
*
|
||||
* @return array{choices: list<string>, choice_label: \Closure(string): string}
|
||||
*/
|
||||
private function choices(Question $question): array
|
||||
{
|
||||
return [
|
||||
'choices' => array_map(static fn (Option $option): string => $option->key, $question->options),
|
||||
'choice_label' => static fn (string $key): string => $question->getOption($key)->label,
|
||||
];
|
||||
}
|
||||
|
||||
private function isEmpty(mixed $answer): bool
|
||||
{
|
||||
return null === $answer || '' === $answer || [] === $answer;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Form;
|
||||
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -27,6 +28,13 @@ class SeasonTaskDeadlineType extends AbstractType
|
||||
'required' => false,
|
||||
'help' => 'Ohne Angabe gilt Frist 1.',
|
||||
])
|
||||
// unmapped: the controller copies the entered deadlines onto the other entries
|
||||
->add('applyToAll', CheckboxType::class, [
|
||||
'label' => 'auf alle Fristen anwenden',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
'help' => 'Gilt für alle noch nicht erledigten Einträge. Leere Felder setzen deren Fristen auf den berechneten Wert zurück.',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\StaffingRule;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class StaffingRuleType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('productCode', TextType::class, [
|
||||
'label' => 'Produktcode',
|
||||
'help' => 'Der Code der Reise in BusPro, nicht der eines Termins (z. B. DWWMP, nicht DWWMW)',
|
||||
])
|
||||
->add('guides', IntegerType::class, [
|
||||
'label' => 'Anzahl Reiseleitungen',
|
||||
'attr' => ['min' => 0],
|
||||
])
|
||||
->add('skiBeginner', CheckboxType::class, [
|
||||
'label' => 'Ski Anfänger:innen',
|
||||
'required' => false,
|
||||
])
|
||||
->add('skiAdvanced', CheckboxType::class, [
|
||||
'label' => 'Ski Fortgeschrittene',
|
||||
'required' => false,
|
||||
])
|
||||
->add('splitBeginner', CheckboxType::class, [
|
||||
'label' => 'Reiseleitung übernimmt Ski Anfänger:innen',
|
||||
'help' => 'Reise- und Kursleitung statt einer eigenen Kursleitung',
|
||||
'required' => false,
|
||||
])
|
||||
->add('splitAdvanced', CheckboxType::class, [
|
||||
'label' => 'Reiseleitung übernimmt Ski Fortgeschrittene',
|
||||
'help' => 'Reise- und Kursleitung statt einer eigenen Kursleitung',
|
||||
'required' => false,
|
||||
])
|
||||
->add('guiding', CheckboxType::class, [
|
||||
'label' => 'Guiding',
|
||||
'required' => false,
|
||||
])
|
||||
->add('enabled', CheckboxType::class, [
|
||||
'label' => 'aktiv',
|
||||
'help' => 'Nur aktive Zeilen legen Einsätze an; die Vorschau zeigt alle',
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => StaffingRule::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use App\Service\Common\PickupOptions;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
@@ -19,12 +18,10 @@ class TeamerApplicationType extends AbstractType
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('requests', TextareaType::class, [
|
||||
->add('requests', AutosizeTextareaType::class, [
|
||||
'label' => 'Wünsche',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 3,
|
||||
],
|
||||
'min_rows' => 3,
|
||||
])
|
||||
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
|
||||
/** @var Application $application */
|
||||
|
||||
@@ -7,7 +7,6 @@ use App\Entity\Teamer;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
@@ -59,12 +58,10 @@ class TeamerJobProfileType extends AbstractType
|
||||
'expanded' => true,
|
||||
'multiple' => true,
|
||||
])
|
||||
->add('remarks', TextareaType::class, [
|
||||
->add('remarks', AutosizeTextareaType::class, [
|
||||
'label' => 'Wünsche/Anmerkungen',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 3,
|
||||
],
|
||||
'min_rows' => 3,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Enum\WorkMonthStatus;
|
||||
use App\Model\WorkMonthReviewDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -27,12 +26,10 @@ class WorkMonthReviewType extends AbstractType
|
||||
],
|
||||
'choice_value' => static fn (?WorkMonthStatus $status) => $status?->value,
|
||||
])
|
||||
->add('comment', TextareaType::class, [
|
||||
->add('comment', AutosizeTextareaType::class, [
|
||||
'label' => 'Kommentar/Begründung',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 3,
|
||||
],
|
||||
'min_rows' => 3,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -97,10 +97,10 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
]);
|
||||
|
||||
$menu->addChild('Feedbackübersicht', [
|
||||
$menu->addChild('Teamer:innen-Feedback', [
|
||||
'route' => 'app_administrative_feedback_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Feedbackübersicht',
|
||||
'title' => 'Teamer:innen-Feedback',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'feedback',
|
||||
@@ -109,6 +109,18 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Reisefeedback', [
|
||||
'route' => 'app_administrative_questionnaire_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Reisefeedback nach Einsätzen',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'document',
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_questionnaire_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$menu->addChild('Teamübersicht', [
|
||||
'route' => 'app_administrative_teamer_index',
|
||||
@@ -266,6 +278,17 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
],
|
||||
]);
|
||||
$settingsMenu->addChild('Besetzungsmatrix', [
|
||||
'route' => 'app_administrative_system_staffing_rule_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Besetzungsmatrix',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_system_staffing_rule_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$settingsMenu->addChild('Reisen', [
|
||||
'route' => 'app_administrative_system_destination_index',
|
||||
'linkAttributes' => [
|
||||
@@ -406,14 +429,14 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Vergangene Einsätze', [
|
||||
'route' => 'app_administrative_teamer_recent_assignments',
|
||||
'route' => 'app_administrative_teamer_recent_dispositions',
|
||||
'routeParameters' => $this->getDefaultRouteParameters('uuid'),
|
||||
'linkAttributes' => [
|
||||
'title' => 'Vergangene Einsätze',
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Bevorstehende Einsätze', [
|
||||
'route' => 'app_administrative_teamer_upcoming_assignments',
|
||||
'route' => 'app_administrative_teamer_upcoming_dispositions',
|
||||
'routeParameters' => $this->getDefaultRouteParameters('uuid'),
|
||||
'linkAttributes' => [
|
||||
'title' => 'Bevorstehende Einsätze',
|
||||
|
||||
@@ -106,10 +106,10 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Feedbackübersicht', [
|
||||
$menu->addChild('Teamer:innen-Feedback', [
|
||||
'route' => 'app_administrative_feedback_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Feedbackübersicht',
|
||||
'title' => 'Teamer:innen-Feedback',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'feedback',
|
||||
@@ -118,6 +118,18 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Reisefeedback', [
|
||||
'route' => 'app_administrative_questionnaire_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Reisefeedback',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'document',
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_questionnaire_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$statisticsMenu = $menu->addChild('Statistiken', [
|
||||
'extras' => [
|
||||
'icon' => 'chart',
|
||||
@@ -155,6 +167,17 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
|
||||
'icon' => 'settings',
|
||||
],
|
||||
]);
|
||||
$settingsMenu->addChild('Besetzungsmatrix', [
|
||||
'route' => 'app_administrative_system_staffing_rule_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Besetzungsmatrix',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_system_staffing_rule_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$settingsMenu->addChild('Reisen', [
|
||||
'route' => 'app_administrative_system_destination_index',
|
||||
'linkAttributes' => [
|
||||
@@ -231,14 +254,14 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Vergangene Einsätze', [
|
||||
'route' => 'app_administrative_teamer_recent_assignments',
|
||||
'route' => 'app_administrative_teamer_recent_dispositions',
|
||||
'routeParameters' => $this->getDefaultRouteParameters('uuid'),
|
||||
'linkAttributes' => [
|
||||
'title' => 'Vergangene Einsätze',
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Bevorstehende Einsätze', [
|
||||
'route' => 'app_administrative_teamer_upcoming_assignments',
|
||||
'route' => 'app_administrative_teamer_upcoming_dispositions',
|
||||
'routeParameters' => $this->getDefaultRouteParameters('uuid'),
|
||||
'linkAttributes' => [
|
||||
'title' => 'Bevorstehende Einsätze',
|
||||
|
||||
@@ -54,22 +54,6 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
]);
|
||||
|
||||
// Only for an all-season teamer who actually has an assignment this season: the page
|
||||
// would have nothing to show otherwise, and it throws a 404 in exactly that case.
|
||||
if (null !== $this->resolveSeasonAssignment()) {
|
||||
$profileMenu->addChild('Saison-Teamer:in', [
|
||||
'route' => 'app_teamer_season_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Saison-Teamer:in',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_teamer_season_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// Season teamers only, and on the flag rather than on a current assignment: a month
|
||||
// still has to be reachable after the season it belongs to has ended.
|
||||
if (true === $this->isSeasonTeamer()) {
|
||||
@@ -110,6 +94,23 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
'icon' => 'bus',
|
||||
],
|
||||
]);
|
||||
|
||||
// Only for an all-season teamer who actually has an assignment this season: the page
|
||||
// would have nothing to show otherwise, and it throws a 404 in exactly that case.
|
||||
if (null !== $this->resolveSeasonAssignment()) {
|
||||
$dispositionMenu->addChild('Saison-Einsatz', [
|
||||
'route' => 'app_teamer_season_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Saison-Einsatz',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_teamer_season_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$dispositionMenu->addChild('Merkliste', [
|
||||
'route' => 'app_teamer_bookmark_index',
|
||||
'linkAttributes' => [
|
||||
@@ -128,10 +129,16 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
'title' => 'Nächste Einsätze',
|
||||
],
|
||||
]);
|
||||
$dispositionMenu->addChild('Letze Einsätze', [
|
||||
$dispositionMenu->addChild('Letzte Einsätze', [
|
||||
'route' => 'app_teamer_disposition_recent',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Letze Einsätze',
|
||||
'title' => 'Letzte Einsätze',
|
||||
],
|
||||
// the questionnaire after a past bus escort
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_teamer_questionnaire_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
/**
|
||||
* One submitted TYPO3 form, mapped from the request body by #[MapRequestPayload].
|
||||
*
|
||||
* Two rules from the contract (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.2, §3.3) shape this
|
||||
* class and must survive any rewrite:
|
||||
* Two rules from the contract with the TYPO3 side shape this class and must survive any
|
||||
* rewrite:
|
||||
*
|
||||
* - **Unknown keys are ignored, never rejected.** The mapped vocabulary grows on the TYPO3
|
||||
* side without a release here, so nothing may switch the serializer's ALLOW_EXTRA_ATTRIBUTES
|
||||
@@ -21,8 +21,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* Vorname, Geschlecht, EMail, PLZ, Straße) - a payload without those cannot become a teamer at
|
||||
* all, and asking a reviewer to retype them from the answers would be worse than refusing the
|
||||
* payload. The endpoint only ever receives application forms, so this holds for every payload and
|
||||
* there is no form-type discriminator
|
||||
* (.ddev/plans/application-form-teamer-provisioning.md).
|
||||
* there is no form-type discriminator.
|
||||
*
|
||||
* Everything beyond the nine - phone, mobile, nationality, bank details - stays optional and is
|
||||
* filled in by the teamer themselves afterwards, so an absent key there still means "not part of
|
||||
@@ -334,7 +333,7 @@ class ApplicationFormSubmissionDto
|
||||
/**
|
||||
* The blob as it is stored: numbers arrive as strings from the form editor but nothing stops
|
||||
* a mapping from sending one unquoted, and an empty answer is dropped rather than kept -
|
||||
* TYPO3 strips those before sending (§3.3), so a label present in the blob always has
|
||||
* TYPO3 strips those before sending, so a label present in the blob always has
|
||||
* content. The received order is the form's order and is never touched.
|
||||
*
|
||||
* @return array<string, string|bool|list<string>>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* The date range of GET /api/assignments, mapped from the query string. The parameter names follow
|
||||
* the app's English URL vocabulary, like the path.
|
||||
*/
|
||||
class AssignmentExportQueryDto
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\NotBlank(message: 'from must not be empty.')]
|
||||
#[Assert\Date(message: 'from must be a date as YYYY-MM-DD.')]
|
||||
public readonly ?string $from = null,
|
||||
#[Assert\NotBlank(message: 'to must not be empty.')]
|
||||
#[Assert\Date(message: 'to must be a date as YYYY-MM-DD.')]
|
||||
#[Assert\GreaterThanOrEqual(propertyPath: 'from', message: 'to must not be before from.')]
|
||||
public readonly ?string $to = null,
|
||||
) {
|
||||
}
|
||||
|
||||
// Not getFrom()/getTo(): the property accessor prefers a getter over the public property, so
|
||||
// the GreaterThanOrEqual above would compare the string "to" with a DateTimeImmutable.
|
||||
public function getFromDate(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable($this->from);
|
||||
}
|
||||
|
||||
public function getToDate(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable($this->to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Enum\StaffingFee;
|
||||
use App\Enum\StaffingRole;
|
||||
|
||||
/**
|
||||
* One assignment App\Service\Assignment\AssignmentPlanner would create, before its role and
|
||||
* fees are resolved to entities.
|
||||
*/
|
||||
final readonly class PlannedAssignment
|
||||
{
|
||||
/**
|
||||
* @param list<StaffingFee> $fees
|
||||
*/
|
||||
public function __construct(
|
||||
public StaffingRole $role,
|
||||
public int $places,
|
||||
/** busProId of the pre-selected boarding point ("Busbegleitung ab"), null for none */
|
||||
public ?int $pickup = null,
|
||||
public array $fees = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
use App\Enum\ConditionOperator;
|
||||
|
||||
/**
|
||||
* A question's show_if: it is shown, and can be required, only while this holds.
|
||||
*/
|
||||
readonly class Condition
|
||||
{
|
||||
/**
|
||||
* @param string|list<string>|int $value an option key for EQUALS, a list of them for IN,
|
||||
* a rating for LTE
|
||||
*/
|
||||
public function __construct(
|
||||
public string $question,
|
||||
public ConditionOperator $operator,
|
||||
public string|array|int $value,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the controlling question's answer satisfies this condition. An unanswered question
|
||||
* satisfies none: a follow-up stays hidden until there is something to follow up on.
|
||||
*
|
||||
* This says nothing about whether the controlling question is itself shown; that is
|
||||
* Definition::isVisible().
|
||||
*
|
||||
* @param array<string, mixed> $answers keyed by question key
|
||||
*/
|
||||
public function isMet(array $answers): bool
|
||||
{
|
||||
$answer = $answers[$this->question] ?? null;
|
||||
|
||||
return match ($this->operator) {
|
||||
ConditionOperator::EQUALS => $answer === $this->value,
|
||||
ConditionOperator::IN => in_array($answer, $this->value, true),
|
||||
// A rating arrives as an int from the form and from stored JSON alike; anything else
|
||||
// is not a rating.
|
||||
ConditionOperator::LTE => is_int($answer) && $answer <= $this->value,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user