Compare commits
32
Commits
3f806669c6
...
3c34cfa76a
| 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 |
No files matched your search
@@ -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
|
||||
@@ -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.
Generated
+568
-481
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 ]
|
||||
|
||||
@@ -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
|
||||
+52
-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,6 +121,11 @@ 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
|
||||
@@ -151,6 +187,9 @@ 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%'
|
||||
@@ -428,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:
|
||||
@@ -436,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%'
|
||||
@@ -463,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'
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\System\StaffingRule;
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Entity\StaffingRule;
|
||||
use App\Form\StaffingRuleType;
|
||||
@@ -21,8 +21,8 @@ class CreateController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/system/staffing-rule/create', name: 'app_admin_system_staffing_rule_create')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
#[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();
|
||||
@@ -39,10 +39,10 @@ class CreateController extends AbstractController
|
||||
'product_code' => $rule->getProductCode(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_system_staffing_rule_index'));
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_system_staffing_rule_index'));
|
||||
}
|
||||
|
||||
return $this->render('admin/system/staffing_rule/modal_create.html.twig', [
|
||||
return $this->render('administrative/system/staffing_rule/modal_create.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\System\StaffingRule;
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Entity\StaffingRule;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
@@ -20,8 +20,8 @@ class DeleteController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/system/staffing-rule/delete/{uuid}', name: 'app_admin_system_staffing_rule_delete')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
#[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')) {
|
||||
@@ -34,10 +34,10 @@ class DeleteController extends AbstractController
|
||||
'product_code' => $rule->getProductCode(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_system_staffing_rule_index'));
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_system_staffing_rule_index'));
|
||||
}
|
||||
|
||||
return $this->render('admin/system/staffing_rule/modal_delete.html.twig', [
|
||||
return $this->render('administrative/system/staffing_rule/modal_delete.html.twig', [
|
||||
'rule' => $rule,
|
||||
]);
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\System\StaffingRule;
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Entity\StaffingRule;
|
||||
use App\Form\StaffingRuleType;
|
||||
@@ -21,8 +21,8 @@ class EditController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/system/staffing-rule/edit/{uuid}', name: 'app_admin_system_staffing_rule_edit')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
#[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);
|
||||
@@ -37,10 +37,10 @@ class EditController extends AbstractController
|
||||
'product_code' => $rule->getProductCode(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_system_staffing_rule_index'));
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_system_staffing_rule_index'));
|
||||
}
|
||||
|
||||
return $this->render('admin/system/staffing_rule/modal_edit.html.twig', [
|
||||
return $this->render('administrative/system/staffing_rule/modal_edit.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\System\StaffingRule;
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Service\Assignment\StaffingConfigurationException;
|
||||
use App\Service\Export\StaffingPreviewExporter;
|
||||
@@ -21,8 +21,8 @@ class ExportController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/system/staffing-rule/export', name: 'app_admin_system_staffing_rule_export')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
#[Route('/administrative/system/staffing-rule/export', name: 'app_administrative_system_staffing_rule_export')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(): Response
|
||||
{
|
||||
try {
|
||||
@@ -30,13 +30,13 @@ class ExportController extends AbstractController
|
||||
} catch (StaffingConfigurationException $e) {
|
||||
$this->addFlash('error', 'Ein Jobprofil oder Honorar der Besetzungsmatrix ist nicht (mehr) vorhanden: '.$e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('app_admin_system_staffing_rule_index');
|
||||
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_admin_system_staffing_rule_preview');
|
||||
return $this->redirectToRoute('app_administrative_system_staffing_rule_preview');
|
||||
}
|
||||
|
||||
$spreadsheet = $this->previewExporter->export($rows);
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\System\StaffingRule;
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Repository\StaffingRuleRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -14,11 +14,11 @@ class IndexController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/system/staffing-rule', name: 'app_admin_system_staffing_rule_index')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
#[Route('/administrative/system/staffing-rule', name: 'app_administrative_system_staffing_rule_index')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('admin/system/staffing_rule/index.html.twig', [
|
||||
return $this->render('administrative/system/staffing_rule/index.html.twig', [
|
||||
'rules' => $this->staffingRuleRepository->getList(),
|
||||
]);
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\System\StaffingRule;
|
||||
namespace App\Controller\Administrative\System\StaffingRule;
|
||||
|
||||
use App\Service\Assignment\StaffingConfigurationException;
|
||||
use App\Service\Export\StaffingPreviewExporter;
|
||||
@@ -19,8 +19,8 @@ class PreviewController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/system/staffing-rule/preview', name: 'app_admin_system_staffing_rule_preview')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
#[Route('/administrative/system/staffing-rule/preview', name: 'app_administrative_system_staffing_rule_preview')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
try {
|
||||
@@ -28,7 +28,7 @@ class PreviewController extends AbstractController
|
||||
} catch (StaffingConfigurationException $e) {
|
||||
$this->addFlash('error', 'Ein Jobprofil oder Honorar der Besetzungsmatrix ist nicht (mehr) vorhanden: '.$e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('app_admin_system_staffing_rule_index');
|
||||
return $this->redirectToRoute('app_administrative_system_staffing_rule_index');
|
||||
}
|
||||
|
||||
// The candidates are a computed list, not a query: page the finished rows. The Excel
|
||||
@@ -39,7 +39,7 @@ class PreviewController extends AbstractController
|
||||
$request->query->getInt('limit', 50),
|
||||
);
|
||||
|
||||
return $this->render('admin/system/staffing_rule/preview.html.twig', [
|
||||
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,
|
||||
]);
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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,8 +91,11 @@ 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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -80,6 +80,16 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
#[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();
|
||||
@@ -89,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();
|
||||
@@ -373,4 +384,16 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
|
||||
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>
|
||||
*/
|
||||
|
||||
+38
-16
@@ -55,9 +55,13 @@ class StaffingRule implements BlameableEntityInterface, TimestampableEntityInter
|
||||
#[ORM\Column]
|
||||
private bool $skiAdvanced = false;
|
||||
|
||||
/** The Reiseleitungen take over the ski levels, one level per place. */
|
||||
/** A Reiseleitung takes over the beginners' course (Reise- und Kursleitung), one place. */
|
||||
#[ORM\Column]
|
||||
private bool $split = false;
|
||||
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;
|
||||
@@ -74,20 +78,26 @@ class StaffingRule implements BlameableEntityInterface, TimestampableEntityInter
|
||||
#[Assert\Callback]
|
||||
public function validateCombination(ExecutionContextInterface $context): void
|
||||
{
|
||||
$levels = $this->getSkiLevelCount();
|
||||
|
||||
if (true === $this->split && 0 === $levels) {
|
||||
if (true === $this->splitBeginner && false === $this->skiBeginner) {
|
||||
$context
|
||||
->buildViolation('Ein Split braucht mindestens einen Skikurs')
|
||||
->atPath('split')
|
||||
->buildViolation('Die Reiseleitung kann nur einen Kurs übernehmen, der angeboten wird')
|
||||
->atPath('splitBeginner')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (true === $this->split && $levels > (int) $this->guides) {
|
||||
if (true === $this->splitAdvanced && false === $this->skiAdvanced) {
|
||||
$context
|
||||
->buildViolation('Bei einem Split braucht jedes Kursniveau eine eigene Reiseleitung')
|
||||
->atPath('split')
|
||||
->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()
|
||||
;
|
||||
}
|
||||
@@ -101,9 +111,9 @@ class StaffingRule implements BlameableEntityInterface, TimestampableEntityInter
|
||||
}
|
||||
}
|
||||
|
||||
public function getSkiLevelCount(): int
|
||||
public function getSplitLevelCount(): int
|
||||
{
|
||||
return (int) $this->skiBeginner + (int) $this->skiAdvanced;
|
||||
return (int) $this->splitBeginner + (int) $this->splitAdvanced;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -165,14 +175,26 @@ class StaffingRule implements BlameableEntityInterface, TimestampableEntityInter
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSplit(): bool
|
||||
public function isSplitBeginner(): bool
|
||||
{
|
||||
return $this->split;
|
||||
return $this->splitBeginner;
|
||||
}
|
||||
|
||||
public function setSplit(bool $split): static
|
||||
public function setSplitBeginner(bool $splitBeginner): static
|
||||
{
|
||||
$this->split = $split;
|
||||
$this->splitBeginner = $splitBeginner;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSplitAdvanced(): bool
|
||||
{
|
||||
return $this->splitAdvanced;
|
||||
}
|
||||
|
||||
public function setSplitAdvanced(bool $splitAdvanced): static
|
||||
{
|
||||
$this->splitAdvanced = $splitAdvanced;
|
||||
|
||||
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,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.',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,14 @@ class StaffingRuleType extends AbstractType
|
||||
'label' => 'Ski Fortgeschrittene',
|
||||
'required' => false,
|
||||
])
|
||||
->add('split', CheckboxType::class, [
|
||||
'label' => 'Split',
|
||||
'help' => 'Die Reiseleitungen übernehmen die Kurse (Reise- und Kursleitung)',
|
||||
->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, [
|
||||
|
||||
@@ -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',
|
||||
@@ -267,13 +279,13 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
]);
|
||||
$settingsMenu->addChild('Besetzungsmatrix', [
|
||||
'route' => 'app_admin_system_staffing_rule_index',
|
||||
'route' => 'app_administrative_system_staffing_rule_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Besetzungsmatrix',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_admin_system_staffing_rule_/'],
|
||||
['pattern' => '/^app_administrative_system_staffing_rule_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -417,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_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
/**
|
||||
* One version of a questionnaire as read from config/questionnaires/. Responses store its key and
|
||||
* version, and their answers are keyed by Question::$key.
|
||||
*/
|
||||
readonly class Definition
|
||||
{
|
||||
/**
|
||||
* @param list<Section> $sections
|
||||
*/
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public int $version,
|
||||
public string $title,
|
||||
public ?string $description,
|
||||
public array $sections,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Question> in the order they are asked, keyed by question key
|
||||
*/
|
||||
public function getQuestions(): array
|
||||
{
|
||||
$questions = [];
|
||||
|
||||
foreach ($this->sections as $section) {
|
||||
foreach ($section->questions as $question) {
|
||||
$questions[$question->key] = $question;
|
||||
}
|
||||
}
|
||||
|
||||
return $questions;
|
||||
}
|
||||
|
||||
public function getQuestion(string $key): ?Question
|
||||
{
|
||||
return $this->getQuestions()[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answers worth keeping once the questionnaire is submitted: those to questions that are
|
||||
* shown, and not empty. A draft keeps everything, so switching an answer back restores the
|
||||
* follow-up the respondent had already written.
|
||||
*
|
||||
* @param array<string, mixed> $answers keyed by question key
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function withoutHiddenAnswers(array $answers): array
|
||||
{
|
||||
$kept = [];
|
||||
|
||||
foreach ($this->getQuestions() as $key => $question) {
|
||||
$answer = $answers[$key] ?? null;
|
||||
|
||||
if (null !== $answer && '' !== $answer && [] !== $answer && $this->isVisible($question, $answers)) {
|
||||
$kept[$key] = $answer;
|
||||
}
|
||||
}
|
||||
|
||||
return $kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a question is shown for these answers: it has no show_if, or its condition is met
|
||||
* and the question it depends on is shown in turn. A hidden question's answer counts for
|
||||
* nothing, even if one was given before it was hidden - otherwise a follow-up could stay open
|
||||
* on an answer the respondent can no longer see.
|
||||
*
|
||||
* Terminates because a show_if may only name an earlier question (QuestionnaireCatalog).
|
||||
*
|
||||
* @param array<string, mixed> $answers keyed by question key
|
||||
*/
|
||||
public function isVisible(Question $question, array $answers): bool
|
||||
{
|
||||
if (null === $question->showIf) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$controlling = $this->getQuestion($question->showIf->question);
|
||||
|
||||
return null !== $controlling
|
||||
&& $this->isVisible($controlling, $answers)
|
||||
&& $question->showIf->isMet($answers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
readonly class Option
|
||||
{
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public string $label,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\QuestionType;
|
||||
|
||||
readonly class Question
|
||||
{
|
||||
/**
|
||||
* @param list<Option> $options only for CHOICE and MULTI
|
||||
* @param ChoiceWidget|null $widget only for CHOICE
|
||||
*/
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public string $label,
|
||||
public QuestionType $type,
|
||||
public bool $required = false,
|
||||
public ?string $help = null,
|
||||
public ?ChoiceWidget $widget = null,
|
||||
public array $options = [],
|
||||
public ?Condition $showIf = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getOption(string $key): ?Option
|
||||
{
|
||||
foreach ($this->options as $option) {
|
||||
if ($key === $option->key) {
|
||||
return $option;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
readonly class Section
|
||||
{
|
||||
/**
|
||||
* @param list<Question> $questions
|
||||
*/
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public ?string $title,
|
||||
public array $questions,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
|
||||
class QuestionnaireResponseFilterDto extends AbstractFilterDto
|
||||
{
|
||||
protected ?Teamer $teamer = null;
|
||||
protected ?string $hotel = null;
|
||||
protected ?\DateTimeImmutable $dateFrom = null;
|
||||
protected ?\DateTimeImmutable $dateTo = null;
|
||||
|
||||
public function getTeamer(): ?Teamer
|
||||
{
|
||||
return $this->teamer;
|
||||
}
|
||||
|
||||
public function setTeamer(?Teamer $teamer): static
|
||||
{
|
||||
$this->teamer = $teamer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHotel(): ?string
|
||||
{
|
||||
return $this->hotel;
|
||||
}
|
||||
|
||||
public function setHotel(?string $hotel): static
|
||||
{
|
||||
$this->hotel = $hotel;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDateFrom(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->dateFrom;
|
||||
}
|
||||
|
||||
public function setDateFrom(?\DateTimeImmutable $dateFrom): static
|
||||
{
|
||||
$this->dateFrom = $dateFrom;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDateTo(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->dateTo;
|
||||
}
|
||||
|
||||
public function setDateTo(?\DateTimeImmutable $dateTo): static
|
||||
{
|
||||
$this->dateTo = $dateTo;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -431,4 +431,53 @@ class AssignmentRepository extends ServiceEntityRepository
|
||||
->getSingleColumnResult()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The assignments the n8n assignments feed reports: published, in one of the houses, for one of
|
||||
* the job profiles, and overlapping the range. Placements, their teamers and documents come
|
||||
* fetch-joined - every place row needs them. All placements are loaded, called-off ones included:
|
||||
* a fetch join on a filtered collection would leave the assignment with a partial one, and
|
||||
* Assignment::getPlaces() leaves them out anyway.
|
||||
*
|
||||
* @param list<string> $houseCodes matched against the first three characters of the hotel code
|
||||
* @param list<int> $jobProfileIds
|
||||
*
|
||||
* @return list<Assignment>
|
||||
*/
|
||||
public function findForExport(\DateTimeImmutable $from, \DateTimeImmutable $to, array $houseCodes, array $jobProfileIds): array
|
||||
{
|
||||
if ([] === $houseCodes || [] === $jobProfileIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('assignment');
|
||||
|
||||
return $qb
|
||||
->addSelect('destination', 'jobProfile', 'disposition', 'teamer', 'document')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->innerJoin('assignment.jobProfile', 'jobProfile')
|
||||
->leftJoin('assignment.dispositions', 'disposition')
|
||||
->leftJoin('disposition.teamer', 'teamer')
|
||||
->leftJoin('disposition.documents', 'document')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->lte('destination.dateFrom', ':to'),
|
||||
$qb->expr()->gte('destination.dateTo', ':from'),
|
||||
$qb->expr()->in('SUBSTRING(destination.hotelCode, 1, 3)', ':houseCodes'),
|
||||
$qb->expr()->in('jobProfile.id', ':jobProfileIds'),
|
||||
$qb->expr()->notIn('assignment.status', ':excludedStatuses'),
|
||||
$qb->expr()->isNull('assignment.deletedAt'),
|
||||
$qb->expr()->isNull('destination.deletedAt'),
|
||||
))
|
||||
->orderBy('destination.dateFrom')
|
||||
->addOrderBy('destination.hotelCode')
|
||||
->addOrderBy('assignment.id')
|
||||
->setParameter('from', $from->format('Y-m-d'))
|
||||
->setParameter('to', $to->format('Y-m-d'))
|
||||
->setParameter('houseCodes', $houseCodes)
|
||||
->setParameter('jobProfileIds', $jobProfileIds)
|
||||
->setParameter('excludedStatuses', [Assignment::STATUS_DRAFT, Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\QuestionnaireResponse;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\Upload;
|
||||
use App\Enum\QuestionnaireResponseStatus;
|
||||
use App\Repository\Filter\SeasonPeriodFilter;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\Query\Expr\Comparison;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
@@ -52,6 +56,17 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* All of them, unlike getUpcomingDispositionsByTeamer(): merged with the season assignments
|
||||
* by App\Service\Teamer\UpcomingPlacementCollector, which sorts and pages the result.
|
||||
*
|
||||
* @return Disposition[]
|
||||
*/
|
||||
public function findAllUpcomingByTeamer(Teamer $teamer): array
|
||||
{
|
||||
return $this->getUpcomingDispositionsByTeamerQuery($teamer)->getResult();
|
||||
}
|
||||
|
||||
public function getUpcomingDispositionsByTeamer(Teamer $teamer, int $limit = 5): array
|
||||
{
|
||||
return $this
|
||||
@@ -61,6 +76,90 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* The teamer's placements with a questionnaire still to be submitted - not started, or saved as
|
||||
* a draft: the teamer feedback, and after a bus escort the bus feedback too
|
||||
* (DispositionContext::questionnaires(), spelled out here in DQL). The rules are those of
|
||||
* DispositionVoter::QUESTIONNAIRE - over (today included), on or after $windowStart, neither
|
||||
* called off nor skip-formalities - so every row shown on the dashboard can be opened.
|
||||
*
|
||||
* The responses are joined as arbitrary entities because the association is one-way; the
|
||||
* questionnaire key has to be part of each JOIN condition, or one questionnaire's response
|
||||
* would stand in for the other's.
|
||||
*
|
||||
* Rows are [0 => Disposition, 'teamerStatus' => ?QuestionnaireResponseStatus,
|
||||
* 'busStatus' => ?QuestionnaireResponseStatus].
|
||||
*/
|
||||
public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $windowStart): Query
|
||||
{
|
||||
$qb = $this->createQueryBuilder('disposition');
|
||||
|
||||
return $qb
|
||||
->select('disposition', 'assignment', 'destination', 'teamer_response.status AS teamerStatus', 'bus_response.status AS busStatus')
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->leftJoin(
|
||||
QuestionnaireResponse::class,
|
||||
'teamer_response',
|
||||
Join::ON,
|
||||
'teamer_response.disposition = disposition AND teamer_response.questionnaire = :teamerQuestionnaire'
|
||||
)
|
||||
->leftJoin(
|
||||
QuestionnaireResponse::class,
|
||||
'bus_response',
|
||||
Join::ON,
|
||||
'bus_response.disposition = disposition AND bus_response.questionnaire = :busQuestionnaire'
|
||||
)
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('disposition.teamer', ':teamer'),
|
||||
$qb->expr()->neq('disposition.status', ':dispositionCalledOff'),
|
||||
$qb->expr()->notIn('assignment.status', ':assignmentStatuses'),
|
||||
$qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'),
|
||||
$qb->expr()->gte('destination.dateTo', ':windowStart'),
|
||||
$qb->expr()->lte('destination.dateTo', ':today'),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('teamer_response.id'),
|
||||
$qb->expr()->eq('teamer_response.status', ':draft'),
|
||||
// a bus escort, i.e. Assignment::hasBusEscort(), owing the bus feedback
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNotNull('assignment.pickup'),
|
||||
$qb->expr()->neq('assignment.pickup', 0),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('bus_response.id'),
|
||||
$qb->expr()->eq('bus_response.status', ':draft')
|
||||
)
|
||||
)
|
||||
)
|
||||
))
|
||||
->orderBy('destination.dateTo', \SortDirection::Descending)
|
||||
->setParameter('teamer', $teamer)
|
||||
->setParameter('teamerQuestionnaire', QuestionnaireCatalog::TEAMER_FEEDBACK)
|
||||
->setParameter('busQuestionnaire', QuestionnaireCatalog::BUS_FEEDBACK)
|
||||
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
|
||||
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
|
||||
->setParameter('skipFormalities', false)
|
||||
->setParameter('windowStart', $windowStart, Types::DATE_IMMUTABLE)
|
||||
->setParameter('today', new \DateTimeImmutable('today'), Types::DATE_IMMUTABLE)
|
||||
->setParameter('draft', QuestionnaireResponseStatus::DRAFT)
|
||||
->getQuery()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{disposition: Disposition, draft: bool}>
|
||||
*/
|
||||
public function findPendingQuestionnaireByTeamer(Teamer $teamer, \DateTimeImmutable $windowStart): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (array $row): array => [
|
||||
'disposition' => $row[0],
|
||||
'draft' => QuestionnaireResponseStatus::DRAFT === $row['teamerStatus']
|
||||
|| QuestionnaireResponseStatus::DRAFT === $row['busStatus'],
|
||||
],
|
||||
$this->getPendingQuestionnaireQuery($teamer, $windowStart)->getResult(),
|
||||
);
|
||||
}
|
||||
|
||||
public function getRecentDispositionsByTeamerQuery(Teamer $teamer): Query
|
||||
{
|
||||
$qb = $this->createQueryBuilder('disposition');
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\QuestionnaireResponse;
|
||||
use App\Enum\QuestionnaireResponseStatus;
|
||||
use App\Model\QuestionnaireResponseFilterDto;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<QuestionnaireResponse>
|
||||
*
|
||||
* @method QuestionnaireResponse|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method QuestionnaireResponse|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method QuestionnaireResponse[] findAll()
|
||||
* @method QuestionnaireResponse[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class QuestionnaireResponseRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, QuestionnaireResponse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submitted responses to one questionnaire for the office's list. Drafts stay out: until the
|
||||
* teamer submits, a response is theirs. Filtered and sorted on the live disposition rather
|
||||
* than the snapshot in `context`, which JSON would make neither sortable nor indexable.
|
||||
*/
|
||||
public function getListQuery(string $questionnaire, QuestionnaireResponseFilterDto $filterDto): Query
|
||||
{
|
||||
$qb = $this->createQueryBuilder('response');
|
||||
|
||||
$qb
|
||||
->select('response', 'disposition', 'teamer', 'assignment', 'destination')
|
||||
->innerJoin('response.disposition', 'disposition')
|
||||
->innerJoin('disposition.teamer', 'teamer')
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('response.questionnaire', ':questionnaire'),
|
||||
$qb->expr()->eq('response.status', ':submitted')
|
||||
))
|
||||
->setParameter('questionnaire', $questionnaire)
|
||||
->setParameter('submitted', QuestionnaireResponseStatus::SUBMITTED)
|
||||
;
|
||||
|
||||
if (null !== $teamer = $filterDto->getTeamer()) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->eq('disposition.teamer', ':teamer'))
|
||||
->setParameter('teamer', $teamer)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $hotel = $filterDto->getHotel()) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->like('destination.hotel', ':hotel'))
|
||||
->setParameter('hotel', '%'.addcslashes($hotel, '%_').'%')
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $dateFrom = $filterDto->getDateFrom()) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->gte('destination.dateTo', ':dateFrom'))
|
||||
->setParameter('dateFrom', $dateFrom, Types::DATE_IMMUTABLE)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $dateTo = $filterDto->getDateTo()) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->lte('destination.dateTo', ':dateTo'))
|
||||
->setParameter('dateTo', $dateTo, Types::DATE_IMMUTABLE)
|
||||
;
|
||||
}
|
||||
|
||||
return $qb->getQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, QuestionnaireResponse> keyed by questionnaire key
|
||||
*/
|
||||
public function findByDisposition(Disposition $disposition): array
|
||||
{
|
||||
$responses = [];
|
||||
|
||||
foreach ($this->findBy(['disposition' => $disposition]) as $response) {
|
||||
$responses[$response->getQuestionnaire()] = $response;
|
||||
}
|
||||
|
||||
return $responses;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Entity\Teamer;
|
||||
use App\Model\SeasonAssignmentFilterDto;
|
||||
use App\Repository\Traits\QueryHelperTrait;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -91,6 +92,42 @@ class SeasonAssignmentRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* A teamer's season assignments that have not ended yet, earliest first - listed alongside
|
||||
* the upcoming dispositions. A running season counts, just as a running disposition does.
|
||||
*
|
||||
* @return SeasonAssignment[]
|
||||
*/
|
||||
public function findUpcomingByTeamer(Teamer $teamer): array
|
||||
{
|
||||
return $this
|
||||
->createUpcomingByTeamerQueryBuilder($teamer)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracted so the conditions can be pinned by a test compiling the DQL.
|
||||
*/
|
||||
public function createUpcomingByTeamerQueryBuilder(Teamer $teamer): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('season_assignment');
|
||||
|
||||
return $qb
|
||||
->select('season_assignment', 'job_profile')
|
||||
->innerJoin('season_assignment.jobProfile', 'job_profile')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('season_assignment.teamer', ':teamer'),
|
||||
$qb->expr()->isNull('season_assignment.deletedAt'),
|
||||
$qb->expr()->gte('season_assignment.dateTo', ':today')
|
||||
))
|
||||
->setParameter('teamer', $teamer)
|
||||
->setParameter('today', new \DateTimeImmutable('today'), Types::DATE_IMMUTABLE)
|
||||
->addOrderBy('season_assignment.dateFrom', \SortDirection::Ascending)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no
|
||||
* fixtures, so what is asserted is what the query says, not what it returns.
|
||||
|
||||
@@ -8,38 +8,46 @@ use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
/**
|
||||
* Validates the shared key a sibling website posts with. There is no person to log in and no
|
||||
* account to look up: the key is the identity, and the user it resolves to is the in-memory
|
||||
* "api" user of the api_user_provider - nothing about it may reach the user table, where a row
|
||||
* would be a login to this application's UI.
|
||||
* Validates the shared key a machine caller sends. There is no person to log in and no account to
|
||||
* look up: the key is the identity, and the user it resolves to is one of the in-memory users of
|
||||
* the api_user_provider - nothing about it may reach the user table, where a row would be a login
|
||||
* to this application's UI.
|
||||
*
|
||||
* One key per caller, each resolving to its own user and roles: the websites posting application
|
||||
* forms must not read assignments, and n8n reading assignments must not post forms.
|
||||
*/
|
||||
class ApiKeyTokenHandler implements AccessTokenHandlerInterface
|
||||
{
|
||||
public const USER_IDENTIFIER = 'api';
|
||||
|
||||
/**
|
||||
* @param array<string, string> $apiKeys user identifier => key
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $apiKey,
|
||||
private readonly array $apiKeys,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getUserBadgeFrom(string $accessToken): UserBadge
|
||||
{
|
||||
// An unconfigured key locks the endpoint instead of opening it: hash_equals('', '')
|
||||
// is true, so without this check a deployment that forgot the secret would accept
|
||||
// an empty key from anyone.
|
||||
if ('' === $this->apiKey) {
|
||||
// An unconfigured key locks its caller out instead of opening the door: hash_equals('', '')
|
||||
// is true, so without this filter a deployment that forgot a secret would accept an empty
|
||||
// key from anyone.
|
||||
$configuredKeys = array_filter($this->apiKeys, fn (string $key) => '' !== $key);
|
||||
|
||||
if ([] === $configuredKeys) {
|
||||
$this->logger->error('API key authentication attempted while no key is configured');
|
||||
|
||||
throw new BadCredentialsException('No API key configured.');
|
||||
}
|
||||
|
||||
if (false === hash_equals($this->apiKey, $accessToken)) {
|
||||
$this->logger->warning('API key authentication failed');
|
||||
|
||||
throw new BadCredentialsException('Invalid API key.');
|
||||
foreach ($configuredKeys as $identifier => $key) {
|
||||
if (true === hash_equals($key, $accessToken)) {
|
||||
return new UserBadge((string) $identifier);
|
||||
}
|
||||
}
|
||||
|
||||
return new UserBadge(self::USER_IDENTIFIER);
|
||||
$this->logger->warning('API key authentication failed');
|
||||
|
||||
throw new BadCredentialsException('Invalid API key.');
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,10 @@ class ApplicationVoter extends Voter
|
||||
return match ($attribute) {
|
||||
static::STATUS, static::DELETE => true,
|
||||
static::VIEW => Application::STATUS_REJECTED !== $status,
|
||||
// A called-off placement frees its place, so it does not count here: re-staffing
|
||||
// must not take raising availableDispositions, which would add a place.
|
||||
static::DISPOSE => Application::STATUS_REJECTED !== $status
|
||||
&& $assignment->getAvailableDispositions() > $assignment->getDispositions()->count(),
|
||||
&& $assignment->getAvailableDispositions() > $assignment->getActiveDispositions()->count(),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Security\Voter;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
@@ -22,9 +23,15 @@ class DispositionVoter extends Voter
|
||||
public const FEEDBACK = 'FEEDBACK';
|
||||
public const CALL_OFF = 'CALL_OFF';
|
||||
public const MANAGE_RECEIPTS = 'MANAGE_RECEIPTS';
|
||||
public const QUESTIONNAIRE = 'QUESTIONNAIRE';
|
||||
|
||||
public function __construct(private readonly Security $security)
|
||||
{
|
||||
/**
|
||||
* @param int $questionnaireWindowDays the services.yaml parameter questionnaire_window_days
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly int $questionnaireWindowDays,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
@@ -44,6 +51,7 @@ class DispositionVoter extends Voter
|
||||
static::FEEDBACK,
|
||||
static::CALL_OFF,
|
||||
static::MANAGE_RECEIPTS,
|
||||
static::QUESTIONNAIRE,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -69,6 +77,7 @@ class DispositionVoter extends Voter
|
||||
static::CALL_OFF => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||
&& Disposition::STATUS_CALLED_OFF !== $disposition->getStatus(),
|
||||
static::MANAGE_RECEIPTS => $this->assertManageReceiptsAllowed($token, $disposition),
|
||||
static::QUESTIONNAIRE => $this->assertQuestionnaireAllowed($token, $disposition),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
@@ -104,6 +113,40 @@ class DispositionVoter extends Voter
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the teamer may fill in, and later read, the questionnaires after this placement:
|
||||
* every placement gets the teamer feedback, a bus escort the bus feedback as well
|
||||
* (DispositionContext::questionnaires() - which ones is not this voter's business). Only the
|
||||
* teamer themself: the office reads the submitted responses through ROLE_ADMINISTRATIVE, not
|
||||
* through this attribute.
|
||||
*
|
||||
* "Over" is the destination's end date, not Disposition::STATUS_ENDED: the cron sets that a
|
||||
* run late, and it moves on to checking_invoice and completed while the date stays true. An
|
||||
* assignment ending today counts as over, as in DispositionRepository's feedback queries.
|
||||
*
|
||||
* Skip-formalities placements are left out, as they are from contract, invoice and feedback.
|
||||
*
|
||||
* DispositionRepository::getPendingQuestionnaireQuery() applies the same rules in DQL for the
|
||||
* dashboard; change both together.
|
||||
*/
|
||||
private function assertQuestionnaireAllowed(TokenInterface $token, Disposition $disposition): bool
|
||||
{
|
||||
if (false === $this->assertTeamerAccess($token, $disposition)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === $disposition->isSkipFormalities()
|
||||
|| Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dateTo = $disposition->getAssignment()?->getDestination()?->getDateTo();
|
||||
|
||||
return null !== $dateTo
|
||||
&& $dateTo >= QuestionnaireCatalog::windowStart($this->questionnaireWindowDays)
|
||||
&& $dateTo <= new \DateTimeImmutable('today');
|
||||
}
|
||||
|
||||
private function assertHouseManagerAccess(TokenInterface $token, Disposition $disposition): bool
|
||||
{
|
||||
if (false === $this->security->isGranted('ROLE_HOUSE_MANAGER')) {
|
||||
|
||||
@@ -14,9 +14,10 @@ use App\Service\Common\PickupOptions;
|
||||
*
|
||||
* The rules, agreed with the stakeholders on 2026-10-15:
|
||||
* - one assignment per role, the role's head count as its places;
|
||||
* - without split, each ski level is a Kursleitung of its own, one place;
|
||||
* - with split, a Reiseleitung takes over each level through the combined profile, one place
|
||||
* per level, and the plain Reiseleitung keeps the remaining places;
|
||||
* - each ski level is a Kursleitung of its own, one place, unless the matrix has a Reiseleitung
|
||||
* take it over: then it is the combined profile, one place, and the plain Reiseleitung keeps
|
||||
* the remaining places. Decided per level (2026-11-05), so one Reiseleitung can take over one
|
||||
* level while the other stays a Kursleitung;
|
||||
* - a date with bus travel (boarding points) gets its first boarding point pre-selected as
|
||||
* Busbegleitung on exactly one assignment, the first guide-type one - the plain Reiseleitung
|
||||
* where there is one. Taken from the date, not the matrix: bus travel varies between the
|
||||
@@ -32,39 +33,33 @@ final class AssignmentPlanner
|
||||
*/
|
||||
public function plan(StaffingRule $rule, Destination $destination): array
|
||||
{
|
||||
$roles = [];
|
||||
$guides = (int) $rule->getGuides();
|
||||
$combined = [];
|
||||
$courses = [];
|
||||
|
||||
if (true === $rule->isSplit()) {
|
||||
$combined = [];
|
||||
|
||||
if (true === $rule->isSkiBeginner()) {
|
||||
$combined[] = StaffingRole::GUIDE_SKI_BEGINNER;
|
||||
}
|
||||
|
||||
if (true === $rule->isSkiAdvanced()) {
|
||||
$combined[] = StaffingRole::GUIDE_SKI_ADVANCED;
|
||||
}
|
||||
|
||||
// The matrix validation refuses more levels than guides; max() keeps a row that
|
||||
// slipped past it from planning a negative head count.
|
||||
$roles[] = [StaffingRole::GUIDE, max(0, $guides - count($combined))];
|
||||
|
||||
foreach ($combined as $role) {
|
||||
$roles[] = [$role, 1];
|
||||
}
|
||||
} else {
|
||||
$roles[] = [StaffingRole::GUIDE, $guides];
|
||||
|
||||
if (true === $rule->isSkiBeginner()) {
|
||||
$roles[] = [StaffingRole::SKI_BEGINNER, 1];
|
||||
}
|
||||
|
||||
if (true === $rule->isSkiAdvanced()) {
|
||||
$roles[] = [StaffingRole::SKI_ADVANCED, 1];
|
||||
if (true === $rule->isSkiBeginner()) {
|
||||
if (true === $rule->isSplitBeginner()) {
|
||||
$combined[] = [StaffingRole::GUIDE_SKI_BEGINNER, 1];
|
||||
} else {
|
||||
$courses[] = [StaffingRole::SKI_BEGINNER, 1];
|
||||
}
|
||||
}
|
||||
|
||||
if (true === $rule->isSkiAdvanced()) {
|
||||
if (true === $rule->isSplitAdvanced()) {
|
||||
$combined[] = [StaffingRole::GUIDE_SKI_ADVANCED, 1];
|
||||
} else {
|
||||
$courses[] = [StaffingRole::SKI_ADVANCED, 1];
|
||||
}
|
||||
}
|
||||
|
||||
// The matrix validation refuses more taken-over levels than guides; max() keeps a row
|
||||
// that slipped past it from planning a negative head count.
|
||||
$roles = [
|
||||
[StaffingRole::GUIDE, max(0, (int) $rule->getGuides() - count($combined))],
|
||||
...$combined,
|
||||
...$courses,
|
||||
];
|
||||
|
||||
$pickup = $this->firstPickup($destination);
|
||||
$guiding = $rule->isGuiding();
|
||||
$planned = [];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Common;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Model\AbstractFilterDto;
|
||||
use App\Model\QuestionnaireResponseFilterDto;
|
||||
|
||||
class QuestionnaireResponseFilterHandler extends AbstractFilterHandler
|
||||
{
|
||||
protected string $namespace = 'filter:questionnaire_response';
|
||||
protected string $modelClass = QuestionnaireResponseFilterDto::class;
|
||||
|
||||
public function getFilterSettings(): QuestionnaireResponseFilterDto
|
||||
{
|
||||
$filterDto = new $this->modelClass();
|
||||
|
||||
if (null === $data = $this->getSession()->get($this->namespace)) {
|
||||
return $filterDto;
|
||||
}
|
||||
|
||||
if (isset($data['teamer_id'])) {
|
||||
$filterDto->setTeamer($this->entityManager->getRepository(Teamer::class)->find($data['teamer_id']));
|
||||
}
|
||||
if (isset($data['hotel'])) {
|
||||
$filterDto->setHotel($data['hotel']);
|
||||
}
|
||||
if (isset($data['date_from'])) {
|
||||
$filterDto->setDateFrom($data['date_from']);
|
||||
}
|
||||
if (isset($data['date_to'])) {
|
||||
$filterDto->setDateTo($data['date_to']);
|
||||
}
|
||||
|
||||
return $filterDto;
|
||||
}
|
||||
|
||||
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
|
||||
{
|
||||
/* @var QuestionnaireResponseFilterDto $filterDto */
|
||||
$this->getSession()->set($this->namespace, [
|
||||
'teamer_id' => $filterDto->getTeamer()?->getId(),
|
||||
'hotel' => $filterDto->getHotel(),
|
||||
'date_from' => $filterDto->getDateFrom(),
|
||||
'date_to' => $filterDto->getDateTo(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Questionnaire;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
|
||||
/**
|
||||
* What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire
|
||||
* need not ask it: who, in which role, where, when, and where the bus escort started.
|
||||
*
|
||||
* Kept as plain scalars (dates as Y-m-d), because it is stored as JSON and must stay readable
|
||||
* after the assignment, its destination or the teamer change.
|
||||
*/
|
||||
final class DispositionContext
|
||||
{
|
||||
/**
|
||||
* The questionnaires a placement on this assignment gets, in the order they are shown: every
|
||||
* teamer the teamer feedback, a bus escort the bus feedback as well. Takes the assignment so
|
||||
* the office's test mode can ask before anyone is placed.
|
||||
*
|
||||
* DispositionRepository::getPendingQuestionnaireQuery() spells the same rule in DQL.
|
||||
*
|
||||
* @return list<string> questionnaire keys
|
||||
*/
|
||||
public static function questionnaires(Assignment $assignment): array
|
||||
{
|
||||
return $assignment->hasBusEscort()
|
||||
? [QuestionnaireCatalog::TEAMER_FEEDBACK, QuestionnaireCatalog::BUS_FEEDBACK]
|
||||
: [QuestionnaireCatalog::TEAMER_FEEDBACK];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* teamerName: ?string,
|
||||
* jobProfileName: ?string,
|
||||
* jobProfileCategory: ?string,
|
||||
* destinationProduct: ?string,
|
||||
* hotel: ?string,
|
||||
* hotelCode: ?string,
|
||||
* dateFrom: ?string,
|
||||
* dateTo: ?string,
|
||||
* escortPickup: array{busProId: ?int, city: ?string, street: ?string, time: ?string}|null,
|
||||
* escortPickupDate: ?string,
|
||||
* }
|
||||
*/
|
||||
public static function build(Disposition $disposition): array
|
||||
{
|
||||
$assignment = $disposition->getAssignment();
|
||||
$destination = $assignment?->getDestination();
|
||||
$jobProfile = $assignment?->getJobProfile();
|
||||
$pickup = $assignment?->hasBusEscort() ? $destination?->getPickup($assignment->getPickup()) : null;
|
||||
|
||||
return [
|
||||
'teamerName' => $disposition->getTeamer()?->getFullName(),
|
||||
'jobProfileName' => $jobProfile?->getName(),
|
||||
'jobProfileCategory' => $jobProfile?->getCategory(),
|
||||
'destinationProduct' => $destination?->getProduct(),
|
||||
'hotel' => $destination?->getHotel(),
|
||||
'hotelCode' => $destination?->getHotelCode(),
|
||||
'dateFrom' => $destination?->getDateFrom()?->format('Y-m-d'),
|
||||
'dateTo' => $destination?->getDateTo()?->format('Y-m-d'),
|
||||
'escortPickup' => null === $pickup ? null : [
|
||||
// Pickup::fromArray() fills id, not busProId; the assignment holds the same value
|
||||
'busProId' => $assignment->getPickup(),
|
||||
'city' => $pickup->getCity(),
|
||||
'street' => $pickup->getStreet(),
|
||||
'time' => $pickup->getTime(),
|
||||
],
|
||||
'escortPickupDate' => $assignment?->getEffectivePickupDate()?->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Teamer;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\Teamer;
|
||||
use App\Repository\DispositionRepository;
|
||||
use App\Repository\SeasonAssignmentRepository;
|
||||
|
||||
/**
|
||||
* A teamer's upcoming placements as one list, earliest first.
|
||||
*
|
||||
* "Placement" covers both ways a teamer is placed: a Disposition on an Assignment, or a
|
||||
* SeasonAssignment. It is not an entity, only the name for the two together.
|
||||
*
|
||||
* The two live in separate tables, so no single query can sort or page across both. A teamer
|
||||
* has a few dozen upcoming dispositions at most, so both are loaded in full and merged here;
|
||||
* callers page or slice the result. Each row carries exactly one of the two records.
|
||||
*/
|
||||
class UpcomingPlacementCollector
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DispositionRepository $dispositionRepository,
|
||||
private readonly SeasonAssignmentRepository $seasonAssignmentRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{dateFrom: ?\DateTimeImmutable, dateTo: ?\DateTimeImmutable, disposition: ?Disposition, seasonAssignment: ?SeasonAssignment}>
|
||||
*/
|
||||
public function collect(Teamer $teamer): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
// season assignments first: the sort is stable, so on the same start day the season,
|
||||
// which frames the dispositions inside it, comes before them
|
||||
foreach ($this->seasonAssignmentRepository->findUpcomingByTeamer($teamer) as $seasonAssignment) {
|
||||
$rows[] = [
|
||||
'dateFrom' => $seasonAssignment->getDateFrom(),
|
||||
'dateTo' => $seasonAssignment->getDateTo(),
|
||||
'disposition' => null,
|
||||
'seasonAssignment' => $seasonAssignment,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->dispositionRepository->findAllUpcomingByTeamer($teamer) as $disposition) {
|
||||
$destination = $disposition->getAssignment()?->getDestination();
|
||||
|
||||
$rows[] = [
|
||||
'dateFrom' => $destination?->getDateFrom(),
|
||||
'dateTo' => $destination?->getDateTo(),
|
||||
'disposition' => $disposition,
|
||||
'seasonAssignment' => null,
|
||||
];
|
||||
}
|
||||
|
||||
usort($rows, static fn (array $a, array $b): int => $a['dateFrom'] <=> $b['dateFrom']);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{# Marks a season assignment listed among dispositions. #}
|
||||
<span class="inline-block py-1 px-2 text-xs bg-primary text-white">Saison</span>
|
||||
@@ -11,15 +11,15 @@
|
||||
sich nicht mehr anmelden. Bestehende Einsätze, Dokumente und Feedbacks bleiben erhalten
|
||||
und weiterhin sichtbar.
|
||||
</div>
|
||||
{% if pendingApplicationCount > 0 or upcomingDispositionCount > 0 %}
|
||||
{% if pendingApplicationCount > 0 or upcomingPlacementCount > 0 %}
|
||||
<div class="p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<div class="font-bold pb-2">Achtung: offene Vorgänge</div>
|
||||
<ul class="list-disc list-inside">
|
||||
{% if pendingApplicationCount > 0 %}
|
||||
<li>{{ pendingApplicationCount }} offene Bewerbung(en)</li>
|
||||
{% endif %}
|
||||
{% if upcomingDispositionCount > 0 %}
|
||||
<li>{{ upcomingDispositionCount }} anstehende(r) Einsatz/Einsätze</li>
|
||||
{% if upcomingPlacementCount > 0 %}
|
||||
<li>{{ upcomingPlacementCount }} anstehende(r) Einsatz/Einsätze</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="pt-2">
|
||||
|
||||
@@ -207,6 +207,13 @@
|
||||
veröffentlichen
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'r': return_url() }) }}"
|
||||
class="text-gray-700 hover:text-primary block px-4 py-2 text-sm"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
title="Den Fragebogen für diesen Einsatz ausprobieren, ohne etwas zu speichern">
|
||||
Fragebogen testen
|
||||
</a>
|
||||
</twig:DropDown>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends 'administrative/layout.html.twig' %}
|
||||
|
||||
{% block title %}{{ definition.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set context = response.context %}
|
||||
<h1 class="text-2xl font-bold pb-4">
|
||||
{{ definition.title }}
|
||||
</h1>
|
||||
<p class="pb-8 text-gray-700">
|
||||
{{ context.teamerName|default('-') }}, abgeschickt am {{ response.submittedAt|date('d.m.Y, H:i') }} Uhr
|
||||
</p>
|
||||
<div class="grid lg:grid-cols-3 gap-8 items-start">
|
||||
<div class="lg:col-span-2">
|
||||
{% include 'questionnaire/_answers.html.twig' with { 'answers': response.answers } %}
|
||||
</div>
|
||||
<div>
|
||||
{# the snapshot taken on submit, so it shows the placement as the teamer answered for it #}
|
||||
<h2 class="text-lg font-bold pb-4">
|
||||
Einsatz
|
||||
</h2>
|
||||
<dl class="divide-y divide-gray-100 text-sm leading-6">
|
||||
{% for label, value in {
|
||||
'Teamer:in': context.teamerName|default(null),
|
||||
'Jobprofil': context.jobProfileName|default(null),
|
||||
'Destination': context.destinationProduct|default(null),
|
||||
'Haus': context.hotel|default(null) ? context.hotel ~ ' (' ~ context.hotelCode ~ ')' : null,
|
||||
'Einsatztermin': context.dateFrom|default(null) ? context.dateFrom|date('d.m.Y') ~ ' - ' ~ context.dateTo|date('d.m.Y') : null,
|
||||
'Busbegleitung ab': context.escortPickup|default(null) ? context.escortPickup.city ~ ', ' ~ context.escortPickup.street ~ (context.escortPickup.time ? ', ' ~ context.escortPickup.time ~ ' Uhr' : '') : null,
|
||||
'Abfahrt Hinfahrt': context.escortPickupDate|default(null) ? context.escortPickupDate|date('d.m.Y') : null,
|
||||
} %}
|
||||
<div class="py-3 first:pt-0 grid grid-cols-3 gap-4">
|
||||
<dt class="font-bold text-gray-900">{{ label }}</dt>
|
||||
<dd class="col-span-2 text-gray-700">{{ (value ?? '-') }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% if siblings is not empty %}
|
||||
<h2 class="text-lg font-bold pt-8 pb-2">
|
||||
Zum selben Einsatz
|
||||
</h2>
|
||||
<ul>
|
||||
{% for sibling in siblings %}
|
||||
<li>
|
||||
<a href="{{ path('app_administrative_questionnaire_detail', { 'uuid': sibling.response.uuid, 'r': app.request.query.get('r') }) }}" class="underline">
|
||||
{{ sibling.title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<div class="pt-8">
|
||||
<a href="{{ returnUrl }}" class="underline">
|
||||
zurück
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends 'administrative/layout.html.twig' %}
|
||||
|
||||
{% block title %}Fragebögen: {{ definition.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-start justify-between pb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold pb-2">
|
||||
Fragebögen: {{ definition.title }}
|
||||
</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for other in definitions %}
|
||||
<a href="{{ path('app_administrative_questionnaire_index', { 'questionnaire': other.key }) }}"
|
||||
class="{{ html_classes('btn btn--small', { 'btn--secondary': other.key != definition.key }) }}">
|
||||
{{ other.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
|
||||
<button type="button"
|
||||
class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}"
|
||||
title="Filter"
|
||||
hx-get="{{ path('app_common_questionnaire_response_filter', { 'r': return_url() }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{{ icon('filter', 'w-4 h-4 shrink-0') }}
|
||||
{% if filterDto.active %}
|
||||
<span class="whitespace-nowrap">{{ filterDto.activeFiltersCount }} Filter aktiv</span>
|
||||
{% else %}
|
||||
<span>Filter</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% if filterDto.active %}
|
||||
<a href="{{ path('app_common_questionnaire_response_filter_reset', { 'r': return_url() }) }}" class="btn btn--small btn--secondary">
|
||||
Reset
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Vorname', 'teamer.firstName') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Einsatzzeitraum', 'destination.dateFrom') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Haus', 'destination.hotel') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Abgeschickt', 'response.submittedAt') }}
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for response in pagination %}
|
||||
{% set disposition = response.disposition %}
|
||||
{% set destination = disposition.assignment.destination %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ disposition.teamer.lastName }}
|
||||
</td>
|
||||
<td>
|
||||
{{ disposition.teamer.firstName }}
|
||||
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %}
|
||||
</td>
|
||||
<td>
|
||||
{{ destination.dateFrom|date('d.m.Y') }}-
|
||||
<br>
|
||||
{{ destination.dateTo|date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ destination.hotel }}
|
||||
</td>
|
||||
<td>
|
||||
{{ response.submittedAt|date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ path('app_administrative_questionnaire_detail', { 'uuid': response.uuid, 'r': return_url() }) }}" title="Fragebogen anzeigen">
|
||||
{{ icon('info', 'w-4 h-4') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
Keine Daten...
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ knp_pagination_render(pagination) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends 'administrative/layout.html.twig' %}
|
||||
|
||||
{% block title %}Fragebogen testen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="text-2xl font-bold pb-2">
|
||||
Fragebogen testen
|
||||
</h1>
|
||||
{% include 'questionnaire/_assignment_summary.html.twig' %}
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
'type': 'warning',
|
||||
'message': 'Testmodus: Der Fragebogen verhält sich wie für Teamer:innen, es wird aber nichts gespeichert.'
|
||||
} %}
|
||||
{% set otherVariant = path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'bus_escort': busEscort ? 0 : 1, 'r': app.request.query.get('r') }) %}
|
||||
{% embed '_partials/_alert.html.twig' with { 'type': 'info' } %}
|
||||
{% block message %}
|
||||
{% if busEscort %}
|
||||
Mit dem Bus-Feedback, wie bei einer Busbegleitung.
|
||||
{% else %}
|
||||
Ohne das Bus-Feedback, wie bei einem Einsatz ohne Busbegleitung.
|
||||
{% endif %}
|
||||
{% if busEscort != assignment.hasBusEscort %}
|
||||
Dieser Einsatz hat {{ assignment.hasBusEscort ? 'eine' : 'keine' }} Busbegleitung.
|
||||
{% endif %}
|
||||
<a href="{{ otherVariant }}" class="underline">{{ busEscort ? 'Ohne' : 'Mit' }} Bus-Feedback testen</a>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
{% if submitted is not null %}
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
'type': 'success',
|
||||
'message': 'So würde der abgeschickte Fragebogen gespeichert. Ausgeblendete Fragen fallen dabei weg.'
|
||||
} %}
|
||||
<div class="flex flex-col gap-y-12">
|
||||
{% for item in submitted %}
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold pb-4">
|
||||
{{ item.definition.title }}
|
||||
</h2>
|
||||
{% include 'questionnaire/_answers.html.twig' with { 'definition': item.definition, 'answers': item.answers } %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pt-8">
|
||||
<a href="{{ path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'r': app.request.query.get('r') }) }}" class="btn btn--secondary">
|
||||
Erneut testen
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if draft %}
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
'type': 'success',
|
||||
'message': 'Der Entwurf wäre jetzt gespeichert. Im Testmodus bleiben die Angaben nur im Formular.'
|
||||
} %}
|
||||
{% endif %}
|
||||
{% include 'questionnaire/_form.html.twig' %}
|
||||
{% endif %}
|
||||
|
||||
<div class="pt-8">
|
||||
<a href="{{ returnUrl }}" class="underline">
|
||||
zurück
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -10,6 +10,7 @@
|
||||
</p>
|
||||
{{ form_row(form.deadlineOverride1) }}
|
||||
{{ form_row(form.deadlineOverride2) }}
|
||||
{{ form_row(form.applyToAll) }}
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
Speichern
|
||||
|
||||
+2
-1
@@ -3,8 +3,9 @@
|
||||
{{ form_row(form.productCode) }}
|
||||
{{ form_row(form.guides) }}
|
||||
{{ form_row(form.skiBeginner) }}
|
||||
{{ form_row(form.splitBeginner) }}
|
||||
{{ form_row(form.skiAdvanced) }}
|
||||
{{ form_row(form.split) }}
|
||||
{{ form_row(form.splitAdvanced) }}
|
||||
{{ form_row(form.guiding) }}
|
||||
{{ form_row(form.enabled) }}
|
||||
</div>
|
||||
+11
-6
@@ -1,4 +1,4 @@
|
||||
{% extends 'admin/layout.html.twig' %}
|
||||
{% extends 'administrative/layout.html.twig' %}
|
||||
|
||||
{% block title %}Einstellungen - Besetzungsmatrix{% endblock %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<h1 class="text-2xl font-bold">
|
||||
Besetzungsmatrix
|
||||
</h1>
|
||||
<a href="{{ path('app_admin_system_staffing_rule_preview') }}" class="btn">
|
||||
<a href="{{ path('app_administrative_system_staffing_rule_preview') }}" class="btn">
|
||||
Vorschau
|
||||
</a>
|
||||
</div>
|
||||
@@ -40,7 +40,12 @@
|
||||
<td>{{ rule.guides }}</td>
|
||||
<td>{{ _self.yes_no(rule.skiBeginner) }}</td>
|
||||
<td>{{ _self.yes_no(rule.skiAdvanced) }}</td>
|
||||
<td>{{ _self.yes_no(rule.split) }}</td>
|
||||
<td>
|
||||
{%- set split = [] -%}
|
||||
{%- if rule.splitBeginner %}{% set split = split|merge(['Anfänger:innen']) %}{% endif -%}
|
||||
{%- if rule.splitAdvanced %}{% set split = split|merge(['Fortgeschrittene']) %}{% endif -%}
|
||||
{{- split is empty ? _self.yes_no(false) : split|join(', ') -}}
|
||||
</td>
|
||||
<td>{{ _self.yes_no(rule.guiding) }}</td>
|
||||
<td>{{ _self.yes_no(rule.enabled) }}</td>
|
||||
<td>
|
||||
@@ -48,14 +53,14 @@
|
||||
<button type="button"
|
||||
class="text-red-500"
|
||||
title="Zeile löschen"
|
||||
hx-get="{{ path('app_admin_system_staffing_rule_delete', { 'uuid': rule.uuid }) }}"
|
||||
hx-get="{{ path('app_administrative_system_staffing_rule_delete', { 'uuid': rule.uuid }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{{ icon('delete') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
title="Zeile bearbeiten"
|
||||
hx-get="{{ path('app_admin_system_staffing_rule_edit', { 'uuid': rule.uuid }) }}"
|
||||
hx-get="{{ path('app_administrative_system_staffing_rule_edit', { 'uuid': rule.uuid }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{{ icon('edit') }}
|
||||
@@ -77,7 +82,7 @@
|
||||
<button type="button"
|
||||
class="btn"
|
||||
title="Zeile hinzufügen"
|
||||
hx-get="{{ path('app_admin_system_staffing_rule_create') }}"
|
||||
hx-get="{{ path('app_administrative_system_staffing_rule_create') }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Zeile hinzufügen
|
||||
+1
-1
@@ -3,5 +3,5 @@
|
||||
{% block title %}Zeile hinzufügen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'admin/system/staffing_rule/_form.html.twig' %}
|
||||
{% include 'administrative/system/staffing_rule/_form.html.twig' %}
|
||||
{% endblock %}
|
||||
File renamed without changes.
+1
-1
@@ -3,5 +3,5 @@
|
||||
{% block title %}Zeile bearbeiten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'admin/system/staffing_rule/_form.html.twig' %}
|
||||
{% include 'administrative/system/staffing_rule/_form.html.twig' %}
|
||||
{% endblock %}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
{% extends 'admin/layout.html.twig' %}
|
||||
{% extends 'administrative/layout.html.twig' %}
|
||||
|
||||
{% block title %}Einstellungen - Besetzungsmatrix - Vorschau{% endblock %}
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
Besetzungsmatrix – Vorschau
|
||||
</h1>
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="{{ path('app_admin_system_staffing_rule_index') }}" class="btn">
|
||||
<a href="{{ path('app_administrative_system_staffing_rule_index') }}" class="btn">
|
||||
Zurück zur Matrix
|
||||
</a>
|
||||
<a href="{{ path('app_admin_system_staffing_rule_export') }}" class="btn">
|
||||
<a href="{{ path('app_administrative_system_staffing_rule_export') }}" class="btn">
|
||||
Excel-Export
|
||||
</a>
|
||||
</div>
|
||||
File renamed without changes.
@@ -1,57 +0,0 @@
|
||||
{% extends 'administrative/layout.html.twig' %}
|
||||
|
||||
{% block title %}Bevorstehende Einsätze {{ teamer }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid lg:grid-cols-3 gap-y-8 lg:gap-y-0 lg:gap-x-8">
|
||||
<div>
|
||||
{{ knp_menu_render(knp_menu_get('default_teamer')) }}
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<h1 class="text-2xl font-bold pb-8">
|
||||
Bevorstehende Einsätze {{ teamer }}
|
||||
</h1>
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Zeitraum
|
||||
</th>
|
||||
<th>
|
||||
Einsatz
|
||||
</th>
|
||||
<th>
|
||||
Jobprofil
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for disposition in pagination %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ disposition.assignment.destination.dateFrom|date('d.m.Y') }} - {{ disposition.assignment.destination.dateTo|date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ disposition.assignment.destination.product }}
|
||||
<br>
|
||||
{{ disposition.assignment.destination.hotel }}
|
||||
</td>
|
||||
<td>
|
||||
{{ disposition.assignment.jobProfile.name }}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<td colspan="3">
|
||||
Keine Daten...
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ knp_pagination_render(pagination) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends 'administrative/layout.html.twig' %}
|
||||
|
||||
{% block title %}Bevorstehende Einsätze {{ teamer }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid lg:grid-cols-3 gap-y-8 lg:gap-y-0 lg:gap-x-8">
|
||||
<div>
|
||||
{{ knp_menu_render(knp_menu_get('default_teamer')) }}
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<h1 class="text-2xl font-bold pb-8">
|
||||
Bevorstehende Einsätze {{ teamer }}
|
||||
</h1>
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Zeitraum
|
||||
</th>
|
||||
<th>
|
||||
Einsatz
|
||||
</th>
|
||||
<th>
|
||||
Jobprofil
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in pagination %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ row.dateFrom|date('d.m.Y') }} - {{ row.dateTo|date('d.m.Y') }}
|
||||
</td>
|
||||
{% if row.seasonAssignment is not null %}
|
||||
{% set seasonAssignment = row.seasonAssignment %}
|
||||
<td>
|
||||
{% include '_partials/_season_badge.html.twig' %}
|
||||
<a href="{{ path('app_administrative_season_assignment_detail', { 'uuid': seasonAssignment.uuid, 'r': return_url() }) }}"
|
||||
class="underline">
|
||||
{{ seasonAssignment|season_assignment_label }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ seasonAssignment.jobProfile.name }}
|
||||
</td>
|
||||
{% else %}
|
||||
{% set assignment = row.disposition.assignment %}
|
||||
<td>
|
||||
{{ assignment.destination.product }}
|
||||
<br>
|
||||
{{ assignment.destination.hotel }}
|
||||
</td>
|
||||
<td>
|
||||
{{ assignment.jobProfile.name }}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
Keine Daten...
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ knp_pagination_render(pagination) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}Fragebögen filtern{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(filterForm, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }}
|
||||
<div class="flex flex-col space-y-2 pb-4">
|
||||
{{ form_row(filterForm.teamer) }}
|
||||
{{ form_row(filterForm.hotel) }}
|
||||
{{ form_row(filterForm.dateFrom) }}
|
||||
{{ form_row(filterForm.dateTo) }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
{{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }}
|
||||
{{ form_widget(filterForm.reset, { 'attr': { 'class': 'btn btn--secondary' } }) }}
|
||||
</div>
|
||||
{{ form_rest(filterForm) }}
|
||||
{{ form_end(filterForm) }}
|
||||
{% endblock %}
|
||||
@@ -32,6 +32,12 @@
|
||||
{%- endif -%}
|
||||
{%- endblock form_errors -%}
|
||||
|
||||
{# Muted and smaller than the label, so a help text never outweighs the field it explains. #}
|
||||
{%- block form_help -%}
|
||||
{%- set help_attr = help_attr|merge({'class': (help_attr.class|default('') ~ ' pt-1 text-sm text-gray-500')|trim}) -%}
|
||||
{{ parent() }}
|
||||
{%- endblock form_help -%}
|
||||
|
||||
{%- block form_widget_simple -%}
|
||||
{%- set type = type|default('text') -%}
|
||||
{%- if type != 'hidden' -%}
|
||||
@@ -167,6 +173,71 @@
|
||||
{%- endif -%}
|
||||
{%- endblock pickup_choice_widget -%}
|
||||
|
||||
{#
|
||||
# One questionnaire within a questionnaire form (App\Form\QuestionnaireType), which may hold
|
||||
# several: its title, its description and its sections.
|
||||
#}
|
||||
{%- block questionnaire_part_row -%}
|
||||
<section class="pb-4">
|
||||
<h2 class="text-2xl font-bold pb-2">{{ label }}</h2>
|
||||
{%- if help is not empty -%}
|
||||
<p class="pb-6 text-gray-700">{{ help|nl2br }}</p>
|
||||
{%- endif -%}
|
||||
{%- for child in form -%}
|
||||
{{- form_row(child) -}}
|
||||
{%- endfor -%}
|
||||
</section>
|
||||
{%- endblock questionnaire_part_row -%}
|
||||
|
||||
{#
|
||||
# A section of a questionnaire (App\Form\QuestionnaireType): its title and its questions. The
|
||||
# questions' rows carry data-question-key and, when conditional, data-show-if, which
|
||||
# assets/controllers/questionnaire_controller.js reads to show and hide them.
|
||||
#}
|
||||
{%- block questionnaire_section_row -%}
|
||||
<fieldset class="pb-8">
|
||||
{%- if label is not same as(false) -%}
|
||||
<legend class="text-xl font-bold pb-4">{{ label }}</legend>
|
||||
{%- endif -%}
|
||||
<div class="flex flex-col gap-y-6">
|
||||
{%- for child in form -%}
|
||||
{{- form_row(child) -}}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</fieldset>
|
||||
{%- endblock questionnaire_section_row -%}
|
||||
|
||||
{#
|
||||
# A questionnaire's radio or checkbox list: each answer a whole clickable row that lights up on
|
||||
# hover and stays marked once chosen, so it is plain where to click. Questionnaire-only; the
|
||||
# app's other choice lists keep choice_widget_expanded.
|
||||
#}
|
||||
{%- block questionnaire_choice_widget -%}
|
||||
<div class="flex flex-col gap-y-1" {{ block('widget_container_attributes') }}>
|
||||
{%- for child in form -%}
|
||||
<label for="{{ child.vars.id }}"
|
||||
class="{{ html_classes('flex cursor-pointer select-none items-center gap-x-3 rounded-md px-3 py-2 text-sm ring-1 ring-inset transition-colors hover:bg-gray-100 has-[:checked]:bg-primary/10 has-[:checked]:ring-primary', { 'ring-red-500': errors|length, 'ring-gray-200': not errors|length }) }}">
|
||||
{{- form_widget(child, { attr: { class: 'h-4 w-4 shrink-0 border-gray-300 text-primary focus:ring-primary' } }) -}}
|
||||
<span class="text-gray-900">{{ child.vars.label }}</span>
|
||||
</label>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endblock questionnaire_choice_widget -%}
|
||||
|
||||
{# A 1-5 rating as a row of buttons, the radios themselves hidden. #}
|
||||
{%- block questionnaire_rating_widget -%}
|
||||
<div class="flex gap-x-2" role="radiogroup">
|
||||
{%- for child in form -%}
|
||||
<label class="cursor-pointer">
|
||||
{{- form_widget(child, { attr: { class: 'peer sr-only' } }) -}}
|
||||
<span class="{{ html_classes('flex h-10 w-10 items-center justify-center rounded-md text-sm font-bold ring-1 ring-inset peer-checked:bg-primary peer-checked:text-white peer-checked:ring-primary peer-focus-visible:ring-2', { 'ring-red-500 text-red-500': errors|length, 'ring-gray-300 text-gray-900': not errors|length }) }}">
|
||||
{{- child.vars.label -}}
|
||||
</span>
|
||||
</label>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endblock questionnaire_rating_widget -%}
|
||||
|
||||
{%- block birthday_widget -%}
|
||||
<div class="grid grid-cols-3 gap-x-1">
|
||||
{{ form_widget(form.children['day']) }}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{#
|
||||
# Questionnaire answers, read-only. Expects `definition` (the version they were given under) and
|
||||
# `answers`, keyed by question key. Questions that are hidden or were left open are skipped; an
|
||||
# answer the definition does not know (an option key since renamed) is shown as stored rather
|
||||
# than dropped.
|
||||
#}
|
||||
<div class="flex flex-col gap-y-8">
|
||||
{% for section in definition.sections %}
|
||||
{% set shown = section.questions|filter(question => answers[question.key] is defined and definition.isVisible(question, answers)) %}
|
||||
{% if shown is not empty %}
|
||||
<section>
|
||||
{% if section.title %}
|
||||
<h2 class="text-lg font-bold pb-2">
|
||||
{{ section.title }}
|
||||
</h2>
|
||||
{% endif %}
|
||||
<dl class="divide-y divide-gray-100">
|
||||
{% for question in shown %}
|
||||
{% set answer = answers[question.key] %}
|
||||
<div class="py-4 sm:grid sm:grid-cols-2 sm:gap-4">
|
||||
<dt class="text-sm font-bold leading-6 text-gray-900">
|
||||
{{ question.label|nl2br }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:mt-0">
|
||||
{% if question.type.value == 'rating' %}
|
||||
<span class="inline-flex items-center gap-x-2">
|
||||
<span class="inline-flex items-center">
|
||||
{% for grade in range(1, 5) %}
|
||||
{{ icon('star', html_classes('w-4 h-4', { 'text-gray-200': answer < grade, 'text-primary': answer >= grade })) }}
|
||||
{% endfor %}
|
||||
</span>
|
||||
{{ answer }} von 5
|
||||
</span>
|
||||
{% elseif question.type.value == 'choice' %}
|
||||
{{ (question.option(answer).label ?? answer) }}
|
||||
{% elseif question.type.value == 'multi' %}
|
||||
{{ answer|map(key => (question.option(key).label ?? key))|join(', ') }}
|
||||
{% elseif question.type.value == 'date' %}
|
||||
{{ answer|date('d.m.Y') }}
|
||||
{% else %}
|
||||
{{ answer|nl2br }}
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
{#
|
||||
# The placement a questionnaire is about, in one line above the form: just enough to know which
|
||||
# one it is. Expects `assignment`.
|
||||
#}
|
||||
{% set destination = assignment.destination %}
|
||||
{% set pickup = assignment.hasBusEscort ? destination.pickup(assignment.pickup) : null %}
|
||||
<p class="flex flex-wrap items-center gap-x-2 gap-y-1 pb-8 text-sm text-gray-700">
|
||||
<span class="font-bold text-gray-900">{{ assignment.jobProfile.name }}</span>
|
||||
{% include '_partials/_dot.html.twig' %}
|
||||
<span>{{ destination.product }}</span>
|
||||
{% include '_partials/_dot.html.twig' %}
|
||||
<span class="whitespace-nowrap">{{ destination.dateFrom|date('d.m.') }}–{{ destination.dateTo|date('d.m.Y') }}</span>
|
||||
{% if pickup %}
|
||||
{% include '_partials/_dot.html.twig' %}
|
||||
<span class="whitespace-nowrap">Busbegleitung ab {{ pickup.city }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
@@ -0,0 +1,17 @@
|
||||
{#
|
||||
# A questionnaire form (App\Form\QuestionnaireType) with its two buttons. Shared by the teamer's
|
||||
# page and the office's test mode, so a test shows exactly what a teamer gets.
|
||||
#}
|
||||
{{ form_start(form, { attr: { 'data-controller': 'questionnaire', 'data-action': 'change->questionnaire#update' } }) }}
|
||||
{% for child in form|filter(child => child.vars.name not in ['draft', 'submit']) %}
|
||||
{{ form_row(child) }}
|
||||
{% endfor %}
|
||||
<div class="flex flex-wrap items-center gap-4 pt-4">
|
||||
{{ form_widget(form.submit, { attr: { class: 'btn' } }) }}
|
||||
{{ form_widget(form.draft, { attr: { class: 'btn btn--secondary' } }) }}
|
||||
</div>
|
||||
<p class="pt-2 text-sm text-gray-500">
|
||||
Als Entwurf kannst du den Fragebogen jederzeit speichern und später weiter ausfüllen.
|
||||
Abgeschickt ist er endgültig.
|
||||
</p>
|
||||
{{ form_end(form) }}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% set assignment = disposition.assignment %}
|
||||
<div class="relative flex flex-col justify-between rounded-md border border-gray-200 bg-gray-100 p-4">
|
||||
<div class="flex flex-col space-y-2 pb-2">
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('flag-' ~ assignment.destination.country, 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1 font-bold">{{ assignment.destination.product }}</span>
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('calendar', 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1">{{ assignment.destination.dateFrom|date('d.m.Y') }} - {{ assignment.destination.dateTo|date('d.m.Y') }}</span>
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('profile', 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1">{{ assignment.jobProfile.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('house', 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1">{{ assignment.destination.hotel }}</span>
|
||||
</div>
|
||||
{% if assignment.pickup %}
|
||||
{% set pickup = assignment.destination.pickup(assignment.pickup) %}
|
||||
{% if pickup is not null %}
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('bus', 'w-5 h-5 shrink-0') }}
|
||||
<span>{{ pickup.city }}, {{ pickup.time }} Uhr</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ path('app_teamer_disposition_detail', { 'uuid': disposition.uuid, 'r': return_url() }) }}"
|
||||
class="btn btn--small flex items-center space-x-2">
|
||||
{{ icon('info', 'w-4 h-4 shrink-0') }}
|
||||
<span>Details</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,40 +1,6 @@
|
||||
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-4 pb-4">
|
||||
{% for disposition in pagination %}
|
||||
{% set assignment = disposition.assignment %}
|
||||
<div class="relative flex flex-col justify-between rounded-md border border-gray-200 bg-gray-100 p-4">
|
||||
<div class="flex flex-col space-y-2 pb-2">
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('flag-' ~ assignment.destination.country, 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1 font-bold">{{ assignment.destination.product }}</span>
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('calendar', 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1">{{ assignment.destination.dateFrom|date('d.m.Y') }} - {{ assignment.destination.dateTo|date('d.m.Y') }}</span>
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('profile', 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1">{{ assignment.jobProfile.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('house', 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1">{{ assignment.destination.hotel }}</span>
|
||||
</div>
|
||||
{% if assignment.pickup %}
|
||||
{% set pickup = assignment.destination.pickup(assignment.pickup) %}
|
||||
{% if pickup is not null %}
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('bus', 'w-5 h-5 shrink-0') }}
|
||||
<span>{{ pickup.city }}, {{ pickup.time }} Uhr</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ path('app_teamer_disposition_detail', { 'uuid': disposition.uuid, 'r': return_url() }) }}"
|
||||
class="btn btn--small flex items-center space-x-2">
|
||||
{{ icon('info', 'w-4 h-4 shrink-0') }}
|
||||
<span>Details</span>
|
||||
</a>
|
||||
</div>
|
||||
{% include 'teamer/disposition/_card.html.twig' %}
|
||||
{% else %}
|
||||
Keine Einträge...
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{# A season assignment among the upcoming dispositions. Linked only when it is the one the season
|
||||
page shows - that page resolves a single assignment and 404s for any other. #}
|
||||
<div class="relative flex flex-col justify-between rounded-md border border-gray-200 bg-gray-100 p-4">
|
||||
<div class="flex flex-col space-y-2 pb-2">
|
||||
<div class="flex items-start space-x-2">
|
||||
<span class="flex-1 font-bold">{{ seasonAssignment|season_assignment_label }}</span>
|
||||
{% include '_partials/_season_badge.html.twig' %}
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('calendar', 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1">{{ seasonAssignment.dateFrom|date('d.m.Y') }} - {{ seasonAssignment.dateTo|date('d.m.Y') }}</span>
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('profile', 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1">{{ seasonAssignment.jobProfile.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
{{ icon('house', 'w-5 h-5 shrink-0') }}
|
||||
<span class="flex-1">{{ seasonAssignment.hotelCode|house_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if seasonAssignment is same as(linkedSeasonAssignment) %}
|
||||
<a href="{{ path('app_teamer_season_index') }}"
|
||||
class="btn btn--small flex items-center space-x-2">
|
||||
{{ icon('info', 'w-4 h-4 shrink-0') }}
|
||||
<span>Details</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -219,6 +219,19 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Questionnaire after the placement; the edit page forwards to the read-only view once submitted #}
|
||||
{% if is_granted('QUESTIONNAIRE', disposition) %}
|
||||
<h3 class="text-xl font-bold pb-4">
|
||||
Fragebogen zu deinem Einsatz
|
||||
</h3>
|
||||
<p class="pb-4">
|
||||
Wie lief dein Einsatz? Dein Feedback hilft uns, besser zu werden.
|
||||
</p>
|
||||
<a href="{{ path('app_teamer_questionnaire_edit', { 'uuid': disposition.uuid }) }}" class="btn inline-block mb-4">
|
||||
Zum Fragebogen
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="pt-4">
|
||||
<a href="{{ returnUrl }}" class="underline">
|
||||
zurück
|
||||
|
||||
@@ -6,5 +6,16 @@
|
||||
<h1 class="text-2xl font-bold pb-8">
|
||||
Meine nächsten Einsätze
|
||||
</h1>
|
||||
{% include 'teamer/disposition/_list.html.twig' %}
|
||||
{% endblock %}
|
||||
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-4 pb-4">
|
||||
{% for row in pagination %}
|
||||
{% if row.seasonAssignment is not null %}
|
||||
{% include 'teamer/disposition/_season_card.html.twig' with { 'seasonAssignment': row.seasonAssignment } %}
|
||||
{% else %}
|
||||
{% include 'teamer/disposition/_card.html.twig' with { 'disposition': row.disposition } %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Keine Einträge...
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ knp_pagination_render(pagination, 'paginator/sliding_boxes.html.twig') }}
|
||||
{% endblock %}
|
||||
@@ -15,6 +15,24 @@
|
||||
{% set alertType = news.status == 2 ? 'warning' : 'info' %}
|
||||
{% include '_partials/_alert.html.twig' with { 'type': alertType, 'message': news.message } %}
|
||||
{% endif %}
|
||||
{% if pendingQuestionnaires is not empty %}
|
||||
{% embed '_partials/_alert.html.twig' with { 'type': 'info' } %}
|
||||
{% block message %}
|
||||
<p>Bitte fülle den Fragebogen zu deinem Einsatz aus:</p>
|
||||
<ul class="pt-1">
|
||||
{% for row in pendingQuestionnaires %}
|
||||
{% set destination = row.disposition.assignment.destination %}
|
||||
<li>
|
||||
<a href="{{ path('app_teamer_questionnaire_edit', { 'uuid': row.disposition.uuid }) }}" class="underline">
|
||||
{{ destination.dateFrom|date('d.m.y') }} - {{ destination.dateTo|date('d.m.y') }}, {{ destination.hotel }}
|
||||
</a>
|
||||
{% if row.draft %}(Entwurf gespeichert){% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endif %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div class="bg-gray-100 border border-gray-200 rounded-md px-4 py-2">
|
||||
<h2 class="font-bold text-lg pb-2">
|
||||
@@ -116,20 +134,42 @@
|
||||
Meine Einsätze
|
||||
</h2>
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{% for disposition in upcomingDispositions %}
|
||||
{% set assignment = disposition.assignment %}
|
||||
{% for row in upcomingPlacements %}
|
||||
<li class="py-2 first:pt-0 last:pb-0">
|
||||
<a href="{{ path('app_teamer_disposition_detail', { 'uuid': disposition.uuid }) }}">
|
||||
<div class="flex items-center space-x-1">
|
||||
{{ icon('check', 'w-4 h-4 shrink-0') }}
|
||||
<div class="flex items-center space-x-1 truncate">
|
||||
<span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
|
||||
{% include '_partials/_dot.html.twig' %}
|
||||
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
|
||||
{% if row.seasonAssignment is not null %}
|
||||
{% set seasonAssignment = row.seasonAssignment %}
|
||||
{% set content %}
|
||||
<div class="flex items-center space-x-1">
|
||||
{{ icon('check', 'w-4 h-4 shrink-0') }}
|
||||
<div class="flex items-center space-x-1 truncate">
|
||||
<span class="whitespace-nowrap">{{ row.dateFrom|date('d.m.y') }} - {{ row.dateTo|date('d.m.y') }}</span>
|
||||
{% include '_partials/_dot.html.twig' %}
|
||||
<span class="whitespace-nowrap">{{ seasonAssignment.hotelCode|house_label }}</span>
|
||||
{% include '_partials/_season_badge.html.twig' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-5 whitespace-nowrap">{{ assignment.jobProfile.name }}</div>
|
||||
</a>
|
||||
<div class="pl-5 whitespace-nowrap">{{ seasonAssignment.jobProfile.name }}</div>
|
||||
{% endset %}
|
||||
{% if seasonAssignment is same as(linkedSeasonAssignment) %}
|
||||
<a href="{{ path('app_teamer_season_index') }}">{{ content }}</a>
|
||||
{% else %}
|
||||
{{ content }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% set disposition = row.disposition %}
|
||||
{% set assignment = disposition.assignment %}
|
||||
<a href="{{ path('app_teamer_disposition_detail', { 'uuid': disposition.uuid }) }}">
|
||||
<div class="flex items-center space-x-1">
|
||||
{{ icon('check', 'w-4 h-4 shrink-0') }}
|
||||
<div class="flex items-center space-x-1 truncate">
|
||||
<span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
|
||||
{% include '_partials/_dot.html.twig' %}
|
||||
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-5 whitespace-nowrap">{{ assignment.jobProfile.name }}</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="py-2 first:pt-0 last:pb-0">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends 'teamer/layout.html.twig' %}
|
||||
|
||||
{% block title %}Fragebogen zu deinem Einsatz{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="text-2xl font-bold pb-2">
|
||||
Fragebogen zu deinem Einsatz
|
||||
</h1>
|
||||
{% include 'questionnaire/_assignment_summary.html.twig' with { 'assignment': disposition.assignment } %}
|
||||
<p class="pb-8 text-gray-700">
|
||||
Danke für dein Feedback!
|
||||
</p>
|
||||
<div class="flex flex-col gap-y-12">
|
||||
{% for item in submitted %}
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold pb-1">
|
||||
{{ item.definition.title }}
|
||||
</h2>
|
||||
<p class="pb-6 text-sm text-gray-500">
|
||||
Abgeschickt am {{ item.response.submittedAt|date('d.m.Y, H:i') }} Uhr
|
||||
</p>
|
||||
{% include 'questionnaire/_answers.html.twig' with { 'definition': item.definition, 'answers': item.response.answers } %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% if open %}
|
||||
<a href="{{ path('app_teamer_questionnaire_edit', { 'uuid': disposition.uuid }) }}" class="btn inline-block self-start">
|
||||
Offenen Fragebogen ausfüllen
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends 'teamer/layout.html.twig' %}
|
||||
|
||||
{% block title %}Fragebogen zu deinem Einsatz{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="text-2xl font-bold pb-2">
|
||||
Fragebogen zu deinem Einsatz
|
||||
</h1>
|
||||
{% include 'questionnaire/_assignment_summary.html.twig' with { 'assignment': disposition.assignment } %}
|
||||
{% if lastSaved %}
|
||||
<p class="-mt-6 pb-8 text-sm text-gray-500">
|
||||
Entwurf zuletzt gespeichert am {{ lastSaved|date('d.m.Y, H:i') }} Uhr
|
||||
</p>
|
||||
{% endif %}
|
||||
{% include 'questionnaire/_form.html.twig' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Config;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\ConditionOperator;
|
||||
use App\Enum\QuestionType;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* The definitions are edited by hand, and a typo in one would otherwise surface as a broken form
|
||||
* in front of a teamer. Building every shipped definition here is what proves the files are
|
||||
* valid; the rejection cases pin that the catalog does not quietly accept a malformed one.
|
||||
*/
|
||||
class QuestionnaireCatalogTest extends TestCase
|
||||
{
|
||||
private const string SHIPPED_DIR = __DIR__.'/../../config/questionnaires';
|
||||
|
||||
private string $dir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dir = sys_get_temp_dir().'/questionnaire_catalog_'.bin2hex(random_bytes(4));
|
||||
mkdir($this->dir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
array_map(unlink(...), glob($this->dir.'/*') ?: []);
|
||||
rmdir($this->dir);
|
||||
}
|
||||
|
||||
public function testEveryShippedDefinitionBuilds(): void
|
||||
{
|
||||
$definitions = new QuestionnaireCatalog(self::SHIPPED_DIR)->all();
|
||||
|
||||
$this->assertNotEmpty($definitions);
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
$this->assertNotEmpty($definition->getQuestions(), $definition->key);
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheTwoQuestionnairesAreTheCurrentKeys(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
[QuestionnaireCatalog::BUS_FEEDBACK, QuestionnaireCatalog::TEAMER_FEEDBACK],
|
||||
new QuestionnaireCatalog(self::SHIPPED_DIR)->keys(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testLowRatingReasonIsRequiredOnlyForLowRatings(): void
|
||||
{
|
||||
$question = new QuestionnaireCatalog(self::SHIPPED_DIR)
|
||||
->get(QuestionnaireCatalog::TEAMER_FEEDBACK)
|
||||
->getQuestion('office_briefing_low_reason')
|
||||
;
|
||||
|
||||
$this->assertTrue($question->required);
|
||||
$this->assertSame('office_briefing_rating', $question->showIf->question);
|
||||
$this->assertSame(ConditionOperator::LTE, $question->showIf->operator);
|
||||
$this->assertSame(3, $question->showIf->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google's single "Bitte ankreuzen:" checkbox list allowed "WC defekt" and "WC funktionstüchtig"
|
||||
* together; it was split into one single choice per topic.
|
||||
*/
|
||||
public function testBusEquipmentIsOneSingleChoicePerTopic(): void
|
||||
{
|
||||
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get(QuestionnaireCatalog::BUS_FEEDBACK);
|
||||
|
||||
foreach (['video', 'wc', 'luggage', 'smoking', 'snow_chains'] as $key) {
|
||||
$question = $definition->getQuestion($key);
|
||||
$this->assertSame(QuestionType::CHOICE, $question->type, $key);
|
||||
$this->assertTrue($question->required, $key);
|
||||
}
|
||||
}
|
||||
|
||||
public function testArchivedVersionsAreNotKeys(): void
|
||||
{
|
||||
$this->write('sample', ['version' => 2] + $this->sample());
|
||||
$this->write('sample.v1', $this->sample());
|
||||
|
||||
$this->assertSame(['sample'], new QuestionnaireCatalog($this->dir)->keys());
|
||||
}
|
||||
|
||||
public function testChoiceDefaultsToRadio(): void
|
||||
{
|
||||
$this->write('sample', $this->sample());
|
||||
|
||||
$question = new QuestionnaireCatalog($this->dir)->get('sample')->getQuestion('answer');
|
||||
|
||||
$this->assertSame(ChoiceWidget::RADIO, $question->widget);
|
||||
$this->assertFalse($question->required);
|
||||
}
|
||||
|
||||
public function testOlderVersionIsReadFromItsArchivedFile(): void
|
||||
{
|
||||
$this->write('sample', ['version' => 2] + $this->sample());
|
||||
$this->write('sample.v1', $this->sample(label: 'Alte Frage'));
|
||||
|
||||
$catalog = new QuestionnaireCatalog($this->dir);
|
||||
|
||||
$this->assertSame(2, $catalog->get('sample')->version);
|
||||
$this->assertSame(2, $catalog->get('sample', 2)->version);
|
||||
$this->assertSame('Alte Frage', $catalog->get('sample', 1)->getQuestion('answer')->label);
|
||||
}
|
||||
|
||||
public function testTheWindowStartsTheGivenDaysBeforeToday(): void
|
||||
{
|
||||
$this->assertEquals(new \DateTimeImmutable('today -2 days'), QuestionnaireCatalog::windowStart(2));
|
||||
}
|
||||
|
||||
public function testANegativeWindowFailsLoudly(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
QuestionnaireCatalog::windowStart(-1);
|
||||
}
|
||||
|
||||
public function testUnknownQuestionnaireIsRejected(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
new QuestionnaireCatalog($this->dir)->get('missing');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Closure(array): array $mutate
|
||||
*/
|
||||
#[DataProvider('provideMalformedDefinitions')]
|
||||
public function testMalformedDefinitionIsRejected(\Closure $mutate, string $message): void
|
||||
{
|
||||
$this->write('sample', $mutate($this->sample()));
|
||||
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessage($message);
|
||||
|
||||
new QuestionnaireCatalog($this->dir)->get('sample');
|
||||
}
|
||||
|
||||
public static function provideMalformedDefinitions(): iterable
|
||||
{
|
||||
yield 'misspelt field' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['requried' => true]),
|
||||
'unknown field(s) requried',
|
||||
];
|
||||
yield 'unknown type' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['type' => 'slider']),
|
||||
'unknown type "slider"',
|
||||
];
|
||||
yield 'key not snake_case' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['key' => 'Reason']),
|
||||
'must be lower snake_case',
|
||||
];
|
||||
yield 'duplicate question key' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['key' => 'answer', 'show_if' => null] + $d['sections'][0]['questions'][1]),
|
||||
'duplicate question key',
|
||||
];
|
||||
yield 'options on a text question' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['options' => [['key' => 'a', 'label' => 'A']]]),
|
||||
'options are not allowed on a textarea question',
|
||||
];
|
||||
yield 'choice without options' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 0, ['options' => []]),
|
||||
'options must be a non-empty list',
|
||||
];
|
||||
yield 'duplicate option key' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 0, ['options' => [['key' => 'yes', 'label' => 'Ja'], ['key' => 'yes', 'label' => 'Jawohl']]]),
|
||||
'duplicate option key "yes"',
|
||||
];
|
||||
yield 'widget on a multi question' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 0, ['type' => 'multi', 'widget' => 'dropdown']),
|
||||
'widget is only allowed on a choice question',
|
||||
];
|
||||
yield 'show_if on a later question' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'later', 'equals' => 'yes']]),
|
||||
'"later" is not an earlier question',
|
||||
];
|
||||
yield 'show_if on a non-existent option' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'answer', 'equals' => 'maybe']]),
|
||||
'"maybe" is not an option of "answer"',
|
||||
];
|
||||
yield 'show_if with two operators' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'answer', 'equals' => 'yes', 'in' => ['no']]]),
|
||||
'needs exactly one of',
|
||||
];
|
||||
yield 'lte on a choice question' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'answer', 'lte' => 3]]),
|
||||
'lte needs a rating question',
|
||||
];
|
||||
yield 'lte outside the rating scale' => [
|
||||
static fn (array $d): array => self::withQuestion(self::withQuestion($d, 0, ['type' => 'rating', 'options' => null]), 1, ['show_if' => ['question' => 'answer', 'lte' => 6]]),
|
||||
'lte needs a rating from 1 to 5',
|
||||
];
|
||||
yield 'key does not match the file name' => [
|
||||
static fn (array $d): array => ['key' => 'other'] + $d,
|
||||
'does not match the file name',
|
||||
];
|
||||
}
|
||||
|
||||
public function testArchivedFileWithTheWrongVersionIsRejected(): void
|
||||
{
|
||||
$this->write('sample', ['version' => 3] + $this->sample());
|
||||
$this->write('sample.v1', ['version' => 2] + $this->sample());
|
||||
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessage('version 2 does not match the file name');
|
||||
|
||||
new QuestionnaireCatalog($this->dir)->get('sample', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* A choice "answer" and a textarea "reason" shown when the answer is "yes".
|
||||
*/
|
||||
private function sample(string $label = 'Frage'): array
|
||||
{
|
||||
return [
|
||||
'key' => 'sample',
|
||||
'version' => 1,
|
||||
'title' => 'Beispiel',
|
||||
'sections' => [[
|
||||
'key' => 'main',
|
||||
'questions' => [
|
||||
[
|
||||
'key' => 'answer',
|
||||
'label' => $label,
|
||||
'type' => 'choice',
|
||||
'options' => [['key' => 'yes', 'label' => 'Ja'], ['key' => 'no', 'label' => 'Nein']],
|
||||
],
|
||||
[
|
||||
'key' => 'reason',
|
||||
'label' => 'Warum?',
|
||||
'type' => 'textarea',
|
||||
'show_if' => ['question' => 'answer', 'equals' => 'yes'],
|
||||
],
|
||||
],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges $fields into the sample's question at $index; a null value removes the field.
|
||||
*/
|
||||
private static function withQuestion(array $definition, int $index, array $fields): array
|
||||
{
|
||||
$question = array_merge($definition['sections'][0]['questions'][$index], $fields);
|
||||
$definition['sections'][0]['questions'][$index] = array_filter($question, static fn (mixed $value): bool => null !== $value);
|
||||
|
||||
return $definition;
|
||||
}
|
||||
|
||||
private function write(string $name, array $definition): void
|
||||
{
|
||||
file_put_contents(sprintf('%s/%s.yaml', $this->dir, $name), Yaml::dump($definition, 10));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Config;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Teamer;
|
||||
use App\Service\Questionnaire\DispositionContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Every question of the two Google forms the questionnaires replace ("RL-Feedback 25-26" and
|
||||
* "Bus-Feedback 25-26") is either asked or answered from the placement - none may get lost when
|
||||
* a definition is edited.
|
||||
*
|
||||
* The lists below are the Google forms' questions in their order, with what each became:
|
||||
* "question:<key>" is asked, "context:<field>" is taken from the disposition and snapshotted into
|
||||
* the response (DispositionContext::build()). Google's "Bitte ankreuzen:" checkbox list was split
|
||||
* into five single-choice questions, so it maps to five.
|
||||
*/
|
||||
class QuestionnaireCompletenessTest extends TestCase
|
||||
{
|
||||
private const array TEAMER_FEEDBACK = [
|
||||
'Name' => ['context:teamerName'],
|
||||
'Jobprofil' => ['context:jobProfileName'],
|
||||
'Haus' => ['context:hotel'],
|
||||
'Einsatzzeitraum (Anreisetag)' => ['context:dateFrom'],
|
||||
'Wie gut hast du dich vorab vom Büro gebrieft gefühlt?' => ['question:office_briefing_rating'],
|
||||
'Wenn 1 - 3 Sterne Bewertung, warum?' => ['question:office_briefing_low_reason'],
|
||||
'War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?' => ['question:team_week_seminar'],
|
||||
'Wie gut lief die Reiseorganisation vor Ort?' => ['question:onsite_organisation_rating'],
|
||||
'Gab es organisatorische Probleme?' => ['question:had_organisational_problems'],
|
||||
'Wenn ja, welche?' => ['question:organisational_problems'],
|
||||
'Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?' => ['question:teamer_collaboration_rating'],
|
||||
'Welche Unterstützung hättest du dir zusätzlich gewünscht?' => ['question:support_wished'],
|
||||
'Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?' => ['question:house_manager_problem_handling'],
|
||||
'Gab es Momente, in denen du dich überfordert gefühlt hast?' => ['question:felt_overwhelmed'],
|
||||
'Wenn ja, wodurch?' => ['question:overwhelmed_reason'],
|
||||
'Was hättest du dir anders oder besser gewünscht?' => ['question:wished_differently'],
|
||||
'Würdest du wieder als Reiseleitung für E&P arbeiten?' => ['question:would_work_again'],
|
||||
'Wenn nein, warum nicht?' => ['question:would_not_work_again_reason'],
|
||||
'Du hast nun schon vieles beantwortet, aber nirgendwo konntest du folgende Aspekte loswerden:' => ['question:anything_else'],
|
||||
];
|
||||
|
||||
private const array BUS_FEEDBACK = [
|
||||
'(Datum, ohne Titel)' => ['context:escortPickupDate', 'context:dateTo'],
|
||||
'Hin- oder Rückfahrt' => ['question:direction'],
|
||||
'Reiseziel:' => ['context:destinationProduct'],
|
||||
'Bus-Unternehmen:' => ['question:bus_company'],
|
||||
'Name des Busunternehmens:' => ['question:bus_company_other'],
|
||||
'Busfahrer-Namen:' => ['question:driver_names'],
|
||||
'Reiseleitung:' => ['context:teamerName'],
|
||||
'1. Abfahrtsort' => ['context:escortPickup', 'context:hotel'],
|
||||
'Bus pünktlich am 1. Abfahrtsort?' => ['question:on_time_at_first_pickup'],
|
||||
'Wieviel Verspätung hatte der Bus am 1. Abfahrtsort?' => ['question:delay_at_first_pickup'],
|
||||
'Gesamteindruck des Busses' => ['question:overall_impression'],
|
||||
'Alter des Fahrzeugs' => ['question:vehicle_age'],
|
||||
'Sitzkomfort im Bus' => ['question:seating_comfort'],
|
||||
'Freundlichkeit der Fahrer' => ['question:driver_friendliness'],
|
||||
'Pausenzeiten' => ['question:breaks'],
|
||||
'Temperatur im Bus' => ['question:temperature'],
|
||||
'Verhalten der Gäste im Bus' => ['question:guest_behaviour'],
|
||||
'Waren den Fahrern alle Abfahrtsorte bekannt?' => ['question:drivers_knew_pickups'],
|
||||
'Welche Abfahrtsorte waren nicht bekannt? Gab es dadurch Probleme?' => ['question:unknown_pickups'],
|
||||
'War den Busfahrern der Zielort und die Ausladestelle bekannt?' => ['question:drivers_knew_destination'],
|
||||
'Bitte ankreuzen:' => ['question:video', 'question:wc', 'question:luggage', 'question:smoking', 'question:snow_chains'],
|
||||
'Entstanden während der Fahrt Schäden am Bus?' => ['question:bus_damage'],
|
||||
'Was ist passiert?' => ['question:damage_description'],
|
||||
'Das ging gar nicht / ist besonders hervorzuheben:' => ['question:bus_remarks'],
|
||||
];
|
||||
|
||||
public function testEveryTeamerFeedbackQuestionIsAskedOrDerived(): void
|
||||
{
|
||||
$this->assertComplete(QuestionnaireCatalog::TEAMER_FEEDBACK, self::TEAMER_FEEDBACK);
|
||||
}
|
||||
|
||||
public function testEveryBusFeedbackQuestionIsAskedOrDerived(): void
|
||||
{
|
||||
$this->assertComplete(QuestionnaireCatalog::BUS_FEEDBACK, self::BUS_FEEDBACK);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Google forms had 19 and 24 questions; with the checkbox list split there are 43 places
|
||||
* an answer comes from.
|
||||
*/
|
||||
public function testTheMappingCoversBothGoogleForms(): void
|
||||
{
|
||||
$this->assertCount(19, self::TEAMER_FEEDBACK);
|
||||
$this->assertCount(24, self::BUS_FEEDBACK);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bus escort answers every question of both forms; anyone else every question of the
|
||||
* teamer form.
|
||||
*/
|
||||
public function testABusEscortGetsBothQuestionnairesAndEveryoneTheTeamerOne(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
[QuestionnaireCatalog::TEAMER_FEEDBACK, QuestionnaireCatalog::BUS_FEEDBACK],
|
||||
DispositionContext::questionnaires((new Assignment())->setPickup(4711)),
|
||||
);
|
||||
$this->assertSame(
|
||||
[QuestionnaireCatalog::TEAMER_FEEDBACK],
|
||||
DispositionContext::questionnaires((new Assignment())->setPickup(0)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<string>> $googleQuestions
|
||||
*/
|
||||
private function assertComplete(string $questionnaire, array $googleQuestions): void
|
||||
{
|
||||
$definition = new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get($questionnaire);
|
||||
$contextFields = array_keys(DispositionContext::build(new Disposition(new Application(new Assignment(), new Teamer()))));
|
||||
$mapped = [];
|
||||
|
||||
foreach ($googleQuestions as $label => $targets) {
|
||||
foreach ($targets as $target) {
|
||||
[$kind, $key] = explode(':', $target);
|
||||
|
||||
if ('question' === $kind) {
|
||||
$this->assertNotNull($definition->getQuestion($key), sprintf('"%s" is not asked as "%s"', $label, $key));
|
||||
$mapped[] = $key;
|
||||
} else {
|
||||
$this->assertContains($key, $contextFields, sprintf('"%s" is not snapshotted as "%s"', $label, $key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// and nothing is asked that the Google form did not ask
|
||||
$this->assertSame([], array_values(array_diff(array_keys($definition->getQuestions()), $mapped)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Administrative\Questionnaire;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Destination;
|
||||
use App\Entity\JobProfile;
|
||||
use App\Entity\QuestionnaireResponse;
|
||||
use App\Entity\User;
|
||||
use App\Enum\QuestionType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
/**
|
||||
* The office's test mode: the real questionnaire on any assignment, and nothing saved, whichever
|
||||
* button is pressed. Runs in a transaction that is rolled back; the client keeps its kernel so
|
||||
* every request shares that connection.
|
||||
*/
|
||||
class TestControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
private Assignment $assignment;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient(server: ['HTTPS' => true]);
|
||||
$this->client->disableReboot();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->entityManager->getConnection()->beginTransaction();
|
||||
|
||||
$destination = (new Destination())
|
||||
->setProduct('Davos Klosters - Schwendi')
|
||||
->setHotel('Schwendi')
|
||||
->setHotelCode('DKI')
|
||||
->setHotelBusProId(1)
|
||||
->setDateFrom(new \DateTimeImmutable('+1 month'))
|
||||
->setDateTo(new \DateTimeImmutable('+1 month +7 days'))
|
||||
->setPickups([])
|
||||
;
|
||||
// no escort and still ahead: the test mode does not care
|
||||
$jobProfile = (new JobProfile())->setName('Reiseleitung')->setCategory(JobProfile::CATEGORY_GUIDE);
|
||||
$this->assignment = (new Assignment())
|
||||
->setDestination($destination)
|
||||
->setJobProfile($jobProfile)
|
||||
->setStatus(Assignment::STATUS_PUBLISHED)
|
||||
;
|
||||
$this->entityManager->persist($jobProfile);
|
||||
$this->entityManager->persist($destination);
|
||||
$this->entityManager->persist($this->assignment);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->entityManager->getConnection()->rollBack();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testTheOfficeSeesTheQuestionnaireForAnyAssignment(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('ROLE_MANAGER'));
|
||||
$crawler = $this->open();
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertStringContainsString('Testmodus', $crawler->text());
|
||||
// the assignment has no escort, so the questionnaire comes without the bus part
|
||||
$this->assertStringContainsString('Ohne das Bus-Feedback', $crawler->text());
|
||||
$this->assertCount(0, $crawler->filter('[data-question-key="bus_feedback.bus_company"]'));
|
||||
$this->assertCount(1, $crawler->filter('form[data-controller="questionnaire"]'));
|
||||
}
|
||||
|
||||
public function testAnIncompleteSubmitShowsTheErrorsAndSavesNothing(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
|
||||
$crawler = $this->client->submit($this->open()->selectButton('Absenden')->form());
|
||||
|
||||
// Symfony answers an invalid submitted form with 422, as it would for a teamer
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$this->assertStringContainsString('Bitte beantworte diese Frage.', $crawler->text());
|
||||
$this->assertNothingSaved();
|
||||
}
|
||||
|
||||
public function testADraftKeepsTheAnswersInTheFormAndSavesNothing(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
|
||||
$form = $this->open()->selectButton('Entwurf speichern')->form();
|
||||
$form['questionnaire[teamer_feedback][improvement][wished_differently]'] = 'Mehr Schnee';
|
||||
$crawler = $this->client->submit($form);
|
||||
|
||||
$this->assertStringContainsString('Der Entwurf wäre jetzt gespeichert', $crawler->text());
|
||||
$this->assertSame('Mehr Schnee', $crawler->filter('textarea[name="questionnaire[teamer_feedback][improvement][wished_differently]"]')->text());
|
||||
$this->assertNothingSaved();
|
||||
}
|
||||
|
||||
public function testACompleteSubmitShowsTheAnswersAndSavesNothing(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
|
||||
$form = $this->open()->selectButton('Absenden')->form();
|
||||
$definition = self::getContainer()->get(QuestionnaireCatalog::class)->get(QuestionnaireCatalog::TEAMER_FEEDBACK);
|
||||
|
||||
foreach ($definition->sections as $section) {
|
||||
foreach ($section->questions as $question) {
|
||||
if (!$question->required || null !== $question->showIf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$form[sprintf('questionnaire[%s][%s][%s]', $definition->key, $section->key, $question->key)] = match ($question->type) {
|
||||
QuestionType::CHOICE => $question->options[0]->key,
|
||||
QuestionType::RATING => '5',
|
||||
default => 'Antwort',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$crawler = $this->client->submit($form);
|
||||
|
||||
$this->assertStringContainsString('So würde der abgeschickte Fragebogen gespeichert', $crawler->text());
|
||||
$this->assertStringContainsString('Sehr gut – Probleme wurden schnell', $crawler->text());
|
||||
$this->assertNothingSaved();
|
||||
}
|
||||
|
||||
public function testTheBusPartCanBeTriedOnAnAssignmentWithoutEscort(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
|
||||
$crawler = $this->client->request('GET', sprintf('/administrative/questionnaire/test/%s?bus_escort=1', $this->assignment->getUuid()));
|
||||
|
||||
$this->assertStringContainsString('Mit dem Bus-Feedback', $crawler->text());
|
||||
$this->assertCount(1, $crawler->filter('[data-question-key="bus_feedback.bus_company"]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The app answers a teamer in the office area with a redirect to their dashboard, not a 403.
|
||||
*/
|
||||
public function testATeamerHasNoAccess(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('ROLE_TEAMER'));
|
||||
$this->open();
|
||||
|
||||
$this->assertResponseRedirects('/teamer');
|
||||
}
|
||||
|
||||
private function open(): Crawler
|
||||
{
|
||||
return $this->client->request('GET', sprintf('/administrative/questionnaire/test/%s', $this->assignment->getUuid()));
|
||||
}
|
||||
|
||||
private function assertNothingSaved(): void
|
||||
{
|
||||
$this->assertSame(0, $this->entityManager->getRepository(QuestionnaireResponse::class)->count([]));
|
||||
}
|
||||
|
||||
private function user(string $role): User
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('Olga')
|
||||
->setLastName('Office')
|
||||
->setEmail(sprintf('olga.office.%[email protected]', strtolower($role)))
|
||||
->setRoles([$role])
|
||||
->setBusProAddressId(1)
|
||||
->setBusProPersonId(1)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user