Compare commits

...
32 Commits
Author SHA1 Message Date
fromme 3c34cfa76a feat: offer the questionnaire for placements ended within a rolling window 2026-11-10 09:23:23 +01:00
fromme 9d0cdd7f52 chore: update inline documentation of feedback config files 2026-11-08 14:41:38 +01:00
fromme 9583612fdc refactor: use english naming for the n8n assignments api 2026-11-07 09:33:54 +01:00
fromme 0bc77b7463 feat: expose winter placements to n8n over an api endpoint 2026-11-06 10:09:56 +01:00
fromme 4ff963d1e2 feat: let managers edit the staffing matrix 2026-11-05 17:18:26 +01:00
fromme f9d2ff084d feat: decide the staffing split per ski level 2026-11-05 17:04:24 +01:00
fromme 7aefe88610 chore: replace contract template pdf 2026-11-05 10:57:18 +01:00
fromme 09eea86183 chore: update project dependencies 2026-11-04 16:29:04 +01:00
fromme ddfaa4b440 chore: streamlined naming of feedback menu items 2026-10-28 11:23:21 +01:00
fromme d3b9b0611b chore: update project dependencies 2026-10-28 09:19:05 +01:00
fromme fe0c854f1d refactor: offer the questionnaire test from the assignment overview 2026-10-27 17:37:47 +01:00
fromme 0b736b51fb feat: calmer questionnaire form with selects and clickable answer rows 2026-10-27 17:32:41 +01:00
fromme af97468b2d feat: put the placement in one line above the questionnaire 2026-10-27 17:27:40 +01:00
fromme a0170f5952 refactor: split the questionnaire into teamer and bus feedback 2026-10-27 17:22:22 +01:00
fromme 2f4f9800dd fix: offer the questionnaire after every placement, the bus part to escorts 2026-10-27 17:12:24 +01:00
fromme 9fedc5b5d3 feat: let the office test the questionnaire on any assignment 2026-10-27 17:07:17 +01:00
fromme a35f91ac63 feat: list and show submitted questionnaires to the office 2026-10-27 17:02:51 +01:00
fromme 890e04f8a6 feat: remind teamers of open questionnaires on the dashboard 2026-10-27 16:59:44 +01:00
fromme 5e4e574076 feat: let teamers fill in and read their questionnaire 2026-10-27 16:56:49 +01:00
fromme 2a041d25a0 feat: let the teamer fill in the questionnaire after a bus escort 2026-10-27 16:53:23 +01:00
fromme df6734c939 feat: store questionnaire responses per disposition 2026-10-27 16:51:56 +01:00
fromme 8070fda64d feat: build the questionnaire form from its definition 2026-10-27 16:47:38 +01:00
fromme d06b647dac feat: decide when a conditional questionnaire question is shown 2026-10-27 16:43:14 +01:00
fromme a84275b40e feat: add questionnaire definitions and their catalog 2026-10-27 16:41:46 +01:00
fromme 2ec2a4c93f chore: fix typo in menu item 2026-10-20 17:14:41 +02:00
fromme de4aedd26e refactor: name the per-teamer disposition lists correctly 2026-10-20 17:14:13 +02:00
frommeandClaude Opus 5.5 5090f72fab fix: log a called-off disposition under its own id key
The disposition call-off logged the disposition's id as "assignment_id" and
set the status from the Assignment constant; both now use the disposition's
own name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:13:53 +02:00
fromme c00dd86eb4 feat: list upcoming season assignments among a teamer's dispositions 2026-10-20 17:13:45 +02:00
fromme 300bb2058f feat: apply season task deadlines to all open checklist entries 2026-10-20 16:52:45 +02:00
fromme 3fe510e64e fix: render form help texts smaller and muted 2026-10-20 16:52:32 +02:00
fromme 48691227cb chore: move menu entry to better suiting submenu 2026-10-20 16:34:12 +02:00
fromme 34ff630b75 chore: update project dependencies 2026-10-20 14:21:22 +02:00
121 changed files with 7393 additions and 799 deletions

No files matched your search

+2
View File
@@ -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.
+2
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -13,6 +13,7 @@ security:
memory:
users:
api: { roles: [ ROLE_API ] }
n8n: { roles: [ ROLE_API_ASSIGNMENTS ] }
role_hierarchy:
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
+271
View File
@@ -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
+137
View File
@@ -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
View File
@@ -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'
+35
View File
@@ -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');
}
}
+39
View File
@@ -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');
}
}
+45
View File
@@ -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');
}
}
+389
View File
@@ -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);
}
@@ -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(),
]);
}
@@ -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,
]);
}
@@ -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(),
]);
}
@@ -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);
@@ -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(),
]);
}
@@ -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,
]);
}
@@ -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,
]);
@@ -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),
]);
}
}
+24 -3
View File
@@ -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,
)),
]);
}
}
+82 -1
View File
@@ -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) {
+23
View File
@@ -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;
}
}
+189
View File
@@ -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));
}
}
}
+24
View File
@@ -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
View File
@@ -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;
}
+12
View File
@@ -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';
}
+13
View File
@@ -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';
}
+29
View File
@@ -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,
};
}
}
+22
View File
@@ -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;
}
}
+17
View File
@@ -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,
]);
}
}
+200
View File
@@ -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;
}
}
+8
View File
@@ -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.',
])
;
}
+8 -3
View File
@@ -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, [
+18 -6
View File
@@ -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',
+27 -4
View File
@@ -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',
+25 -18
View File
@@ -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_/'],
],
],
]);
+35
View File
@@ -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);
}
}
+44
View File
@@ -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,
};
}
}
+90
View File
@@ -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);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Model\Questionnaire;
readonly class Option
{
public function __construct(
public string $key,
public string $label,
) {
}
}
+36
View File
@@ -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;
}
}
+16
View File
@@ -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;
}
}
+49
View File
@@ -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()
;
}
}
+99
View File
@@ -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.
+24 -16
View File
@@ -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.');
}
}
+3 -1
View File
@@ -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,
};
}
+45 -2
View File
@@ -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')) {
+27 -32
View File
@@ -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
@@ -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>
@@ -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
@@ -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 %}
@@ -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 %}
@@ -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>
@@ -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 %}
+71
View File
@@ -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>
+17
View File
@@ -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 -35
View File
@@ -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 %}
+52 -12
View File
@@ -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 %}
+263
View File
@@ -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