From 40e01d91b4937deb9ec8caba22c7ce904e37ee45 Mon Sep 17 00:00:00 2001 From: Tom Udding Date: Fri, 10 Jul 2026 12:13:42 +0200 Subject: [PATCH] feat(career): rebuild the public career frontend This replaces the free-form `VacancyCategory` entity with a hard-coded `VacancyCategories` enum and reworks the public career pages, giving the 'Career' menu its own "Companies" entry so companies stay reachable even when they have no active vacancies. The categories (jobs, internships, traineeships, student jobs and thesis projects) now live in the enum, which also carries their singular and plural labels and a badge colour per category. They are stored as an `enumType` column on `VacancyRevision`. A migration backfills the new column from the old slugs and drops the table. The companies overview is a four-per-row card grid with a featured company spotlight, and each card links through to that company's page. A company page puts a details card, an "Open positions" list with one vacancy per category and a list of up to three upcoming activities the company organises in a sidebar, with the description next to it. The vacancies overview is a new `Career:VacancyOverview` live component whose category, company, label and search filters sync to the query string, the same way the activity overview works. A vacancy page mirrors the company one: the company, a coloured category badge, its labels and links out to the external website and any attachment sit in the sidebar, with the description alongside. On the overview the vacancy cards show a small company logo, the coloured badge and a clamped excerpt, and the whole card is a link. The seeders gained companies with vacancies across every category and descriptions of varying length, and a few upcoming activities are now tagged with an organising company. Closes GH-2068. Closes GH-2069. --- assets/styles/app.scss | 1 + assets/styles/modules/_company.scss | 42 ++ config/reference.php | 4 +- migrations/Version20260710070912.php | 109 +++++ .../Career/AdminVacancyCategoryController.php | 25 -- src/Controller/Career/CareerController.php | 107 ++++- src/DataFixtures/Activity/ActivityFixture.php | 20 +- src/DataFixtures/Career/CompanyFixture.php | 381 ++++++++++++++++++ src/Entity/Career/Company.php | 27 +- src/Entity/Career/CompanyJobPackage.php | 6 +- src/Entity/Career/Enums/VacancyCategories.php | 66 +++ src/Entity/Career/Vacancy.php | 7 +- src/Entity/Career/VacancyCategory.php | 167 -------- src/Entity/Career/VacancyRevision.php | 15 +- src/Form/DisablesFieldsTrait.php | 2 +- .../Activity/ActivityRepository.php | 45 +++ src/Repository/Career/CompanyRepository.php | 119 +++++- .../Career/VacancyCategoryRepository.php | 76 ---- src/Repository/Career/VacancyRepository.php | 230 +++++++++-- .../Components/Career/VacancyOverview.php | 167 ++++++++ .../Extensions/ActivityDateRangeExtension.php | 2 +- src/Twig/Extensions/CareerExtension.php | 8 +- src/Util/Application/SplitToken.php | 2 +- .../activity/admin/approvals/review.html.twig | 2 +- .../vacancies/categories/index.html.twig | 32 -- templates/career/company.html.twig | 121 ++++++ templates/career/index.html.twig | 65 ++- templates/career/vacancies.html.twig | 17 + templates/career/vacancy.html.twig | 84 ++++ .../Activity/ActivityOverview.html.twig | 2 +- .../Career/VacancyOverview.html.twig | 70 ++++ .../partials/activity/admin/form.html.twig | 2 +- templates/partials/admin-sidebar.html.twig | 5 - .../partials/application/main-nav.html.twig | 33 +- .../partials/career/vacancy-card.html.twig | 39 ++ .../Career/VacancyRevisionClonerTest.php | 10 +- translations/messages.en.xlf | 98 ++++- translations/messages.nl.xlf | 98 ++++- 38 files changed, 1907 insertions(+), 399 deletions(-) create mode 100644 migrations/Version20260710070912.php delete mode 100644 src/Controller/Career/AdminVacancyCategoryController.php create mode 100644 src/DataFixtures/Career/CompanyFixture.php create mode 100644 src/Entity/Career/Enums/VacancyCategories.php delete mode 100644 src/Entity/Career/VacancyCategory.php delete mode 100644 src/Repository/Career/VacancyCategoryRepository.php create mode 100644 src/Twig/Components/Career/VacancyOverview.php delete mode 100644 templates/career/admin/vacancies/categories/index.html.twig create mode 100644 templates/career/company.html.twig create mode 100644 templates/career/vacancies.html.twig create mode 100644 templates/career/vacancy.html.twig create mode 100644 templates/components/Career/VacancyOverview.html.twig create mode 100644 templates/partials/career/vacancy-card.html.twig diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 746370acac..76dfee3356 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -29,6 +29,7 @@ $enable-negative-margins: true; @import '../../vendor/twbs/bootstrap/scss/buttons'; @import '../../vendor/twbs/bootstrap/scss/button-group'; @import '../../vendor/twbs/bootstrap/scss/breadcrumb'; +@import '../../vendor/twbs/bootstrap/scss/card'; @import '../../vendor/twbs/bootstrap/scss/close'; @import '../../vendor/twbs/bootstrap/scss/containers'; @import '../../vendor/twbs/bootstrap/scss/dropdown'; diff --git a/assets/styles/modules/_company.scss b/assets/styles/modules/_company.scss index e69de29bb2..34a37dca15 100644 --- a/assets/styles/modules/_company.scss +++ b/assets/styles/modules/_company.scss @@ -0,0 +1,42 @@ +// Company overview / vacancy cards. +// +// A flush list-group inside a card draws its dividers with `--bs-list-group-border-color` (a solid colour), which +// reads darker and heavier than the card's own translucent border. Align the two so the dividers sit flush with the +// card edge instead of standing out. +.company-card { + .list-group { + --#{$prefix}list-group-border-color: var(--#{$prefix}card-border-color); + } +} + +// Monogram logo placeholder (until real logo serving exists): a centred initial on a muted square. `-sm` is the small +// inline mark used on a vacancy card next to the title; the detail pages use a full-width banner variant inline. +.career-logo { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--#{$prefix}secondary-color); + background-color: var(--#{$prefix}secondary-bg); +} + +.career-logo-sm { + flex: none; + width: 2.75rem; + height: 2.75rem; + font-size: 1.1rem; + border-radius: var(--#{$prefix}border-radius); +} + +// Clamp the (rendered Markdown) description teaser on a vacancy card, mirroring `.activity-description-content`. +.vacancy-card-excerpt { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + + > :last-child { + margin-bottom: 0; + } +} diff --git a/config/reference.php b/config/reference.php index 204e0f786a..df53979e61 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1815,8 +1815,8 @@ * } * @psalm-type MercureConfig = array{ * hubs?: arrayaddSql('ALTER TABLE VacancyRevision ADD category VARCHAR(255) DEFAULT NULL'); + + // Best-effort backfill from the old free-form categories: map by the (lowercased) English slug where it lines + // up with one of the new fixed categories, otherwise fall back to `jobs`. + $this->addSql(<<<'SQL' + UPDATE VacancyRevision vr + INNER JOIN VacancyCategory vc ON vr.category_id = vc.id + INNER JOIN CareerLocalisedText slug ON slug.id = vc.slug_id + SET vr.category = CASE LOWER(slug.valueEN) + WHEN 'jobs' THEN 'jobs' + WHEN 'internships' THEN 'internships' + WHEN 'traineeships' THEN 'traineeships' + WHEN 'student-jobs' THEN 'student-jobs' + WHEN 'thesis-projects' THEN 'thesis-projects' + ELSE 'jobs' + END + SQL); + $this->addSql('UPDATE VacancyRevision SET category = \'jobs\' WHERE category IS NULL'); + $this->addSql('ALTER TABLE VacancyRevision CHANGE category category VARCHAR(255) NOT NULL'); + + // Drop the now-unused foreign key, index and column. + $this->addSql('ALTER TABLE VacancyRevision DROP FOREIGN KEY FK_FFE914BF12469DE2'); + $this->addSql('DROP INDEX IDX_FFE914BF12469DE2 ON VacancyRevision'); + $this->addSql('ALTER TABLE VacancyRevision DROP category_id'); + + // Remove the category table and the localised texts it owned. The localised texts are orphan-removing in the + // ORM, but that is not a database cascade, so clean them up here (FK checks off to sidestep ordering). + $this->addSql('SET FOREIGN_KEY_CHECKS = 0'); + $this->addSql(<<<'SQL' + DELETE clt FROM CareerLocalisedText clt + INNER JOIN VacancyCategory vc ON clt.id IN (vc.name_id, vc.pluralName_id, vc.slug_id) + SQL); + $this->addSql('DROP TABLE VacancyCategory'); + $this->addSql('SET FOREIGN_KEY_CHECKS = 1'); + } + + public function down(Schema $schema): void + { + // Recreate the category table and its localised-text foreign keys. + $this->addSql('CREATE TABLE VacancyCategory (id INT AUTO_INCREMENT NOT NULL, name_id INT NOT NULL, slug_id INT NOT NULL, hidden TINYINT(1) NOT NULL, pluralName_id INT NOT NULL, UNIQUE INDEX UNIQ_94C618B271179CD6 (name_id), UNIQUE INDEX UNIQ_94C618B2A8BEABC (pluralName_id), UNIQUE INDEX UNIQ_94C618B2311966CE (slug_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE VacancyCategory ADD CONSTRAINT FK_9701A2A671179CD6 FOREIGN KEY (name_id) REFERENCES CareerLocalisedText (id)'); + $this->addSql('ALTER TABLE VacancyCategory ADD CONSTRAINT FK_9701A2A6A8BEABC FOREIGN KEY (pluralName_id) REFERENCES CareerLocalisedText (id)'); + $this->addSql('ALTER TABLE VacancyCategory ADD CONSTRAINT FK_9701A2A6311966CE FOREIGN KEY (slug_id) REFERENCES CareerLocalisedText (id)'); + + // Add the foreign key column back (nullable for now, so we can backfill). + $this->addSql('ALTER TABLE VacancyRevision ADD category_id INT DEFAULT NULL'); + + // Reconstitute the fixed categories as rows so existing revisions can point at them again. The original + // free-form names are unrecoverable, so we recreate them from the enum (Dutch labels are best-effort). + $this->reconstituteCategory('jobs', 'Job', 'Baan', 'Jobs', 'Banen'); + $this->reconstituteCategory('internships', 'Internship', 'Stage', 'Internships', 'Stages'); + $this->reconstituteCategory('traineeships', 'Traineeship', 'Traineeship', 'Traineeships', 'Traineeships'); + $this->reconstituteCategory('student-jobs', 'Student job', 'Studentenbaan', 'Student jobs', 'Studentenbanen'); + $this->reconstituteCategory('thesis-projects', 'Thesis project', 'Afstudeerproject', 'Thesis projects', 'Afstudeerprojecten'); + + // Any revision whose category value is unknown falls back to `jobs`. + $this->addSql('UPDATE VacancyRevision vr INNER JOIN VacancyCategory vc ON vc.id = (SELECT vc2.id FROM VacancyCategory vc2 INNER JOIN CareerLocalisedText s ON s.id = vc2.slug_id WHERE s.valueEN = \'jobs\' LIMIT 1) SET vr.category_id = vc.id WHERE vr.category_id IS NULL'); + + $this->addSql('ALTER TABLE VacancyRevision CHANGE category_id category_id INT NOT NULL'); + $this->addSql('ALTER TABLE VacancyRevision ADD CONSTRAINT FK_FFE914BF12469DE2 FOREIGN KEY (category_id) REFERENCES VacancyCategory (id)'); + $this->addSql('CREATE INDEX IDX_FFE914BF12469DE2 ON VacancyRevision (category_id)'); + $this->addSql('ALTER TABLE VacancyRevision DROP category'); + } + + /** + * Recreates a single {@see \App\Entity\Career\VacancyCategory} row (with its three localised texts) and repoints + * every revision whose enum value matches $slug back at it. Uses session variables, which survive the implicit + * commits of the surrounding statements on the same connection. + */ + private function reconstituteCategory( + string $slug, + string $nameEn, + string $nameNl, + string $pluralEn, + string $pluralNl, + ): void { + $this->addSql('INSERT INTO CareerLocalisedText (valueEN, valueNL) VALUES (:en, :nl)', ['en' => $nameEn, 'nl' => $nameNl]); + $this->addSql('SET @name_id = LAST_INSERT_ID()'); + $this->addSql('INSERT INTO CareerLocalisedText (valueEN, valueNL) VALUES (:en, :nl)', ['en' => $pluralEn, 'nl' => $pluralNl]); + $this->addSql('SET @plural_id = LAST_INSERT_ID()'); + $this->addSql('INSERT INTO CareerLocalisedText (valueEN, valueNL) VALUES (:slug, :slug)', ['slug' => $slug]); + $this->addSql('SET @slug_id = LAST_INSERT_ID()'); + $this->addSql('INSERT INTO VacancyCategory (name_id, pluralName_id, slug_id, hidden) VALUES (@name_id, @plural_id, @slug_id, 0)'); + $this->addSql('UPDATE VacancyRevision SET category_id = LAST_INSERT_ID() WHERE category = :slug', ['slug' => $slug]); + } +} diff --git a/src/Controller/Career/AdminVacancyCategoryController.php b/src/Controller/Career/AdminVacancyCategoryController.php deleted file mode 100644 index f7171aec55..0000000000 --- a/src/Controller/Career/AdminVacancyCategoryController.php +++ /dev/null @@ -1,25 +0,0 @@ -render('career/admin/vacancies/categories/index.html.twig'); - } -} diff --git a/src/Controller/Career/CareerController.php b/src/Controller/Career/CareerController.php index 30efcd378e..2c83c8530b 100644 --- a/src/Controller/Career/CareerController.php +++ b/src/Controller/Career/CareerController.php @@ -4,22 +4,127 @@ namespace App\Controller\Career; +use App\Entity\Career\Enums\VacancyCategories; +use App\Repository\Activity\ActivityRepository; +use App\Repository\Career\CompanyRepository; +use App\Repository\Career\VacancyRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use function shuffle; + #[Route( path: '/career', name: 'career/', )] class CareerController extends AbstractController { + /** + * The number of a company's upcoming activities shown on its detail page. + */ + private const int COMPANY_ACTIVITY_LIMIT = 3; + + public function __construct( + private readonly CompanyRepository $companyRepository, + private readonly VacancyRepository $vacancyRepository, + private readonly ActivityRepository $activityRepository, + ) { + } + + /** + * The public career landing page: an overview of all companies that are currently visible. + */ #[Route( path: '', name: 'index', )] public function index(): Response { - return $this->render('career/index.html.twig'); + $companies = $this->companyRepository->findAllPublic(); + + // Randomise the order so no company is structurally favoured. + shuffle($companies); + + return $this->render( + 'career/index.html.twig', + ['companies' => $companies], + ); + } + + /** + * The public detail page of a single company: its full description, contact details and active vacancies. + */ + #[Route( + path: '/company/{slug}', + name: 'company', + )] + public function company(string $slug): Response + { + $company = $this->companyRepository->findPublicCompanyBySlugName($slug); + + if (null === $company) { + throw $this->createNotFoundException(); + } + + return $this->render( + 'career/company.html.twig', + [ + 'company' => $company, + 'activities' => $this->activityRepository->findUpcomingByCompany( + $company, + self::COMPANY_ACTIVITY_LIMIT, + ), + ], + ); + } + + /** + * The public vacancies overview. All filtering (category, company, labels, search) is handled by the + * {@see \App\Twig\Components\Career\VacancyOverview} live component, which mirrors its state into the query string; + * a company card links here with `?category=...&company=...` pre-applied. + */ + #[Route( + path: '/vacancies', + name: 'vacancies', + )] + public function vacancies(): Response + { + return $this->render('career/vacancies.html.twig'); + } + + /** + * The public detail page of a single vacancy: its full description and the outward link to apply. Identified by the + * owning company, its category and its slug (unique within that pair). + */ + #[Route( + path: '/company/{companySlug}/{category}/{vacancySlug}', + name: 'vacancy', + )] + public function vacancy( + string $companySlug, + string $category, + string $vacancySlug, + ): Response { + $categoryEnum = VacancyCategories::tryFrom($category); + + if (null === $categoryEnum) { + throw $this->createNotFoundException(); + } + + $vacancy = $this->vacancyRepository->findPublicVacancy( + $companySlug, + $categoryEnum, + $vacancySlug, + ); + + if (null === $vacancy) { + throw $this->createNotFoundException(); + } + + return $this->render( + 'career/vacancy.html.twig', + ['vacancy' => $vacancy], + ); } } diff --git a/src/DataFixtures/Activity/ActivityFixture.php b/src/DataFixtures/Activity/ActivityFixture.php index 6a33907c83..75c7420052 100644 --- a/src/DataFixtures/Activity/ActivityFixture.php +++ b/src/DataFixtures/Activity/ActivityFixture.php @@ -4,6 +4,7 @@ namespace App\DataFixtures\Activity; +use App\DataFixtures\Career\CompanyFixture; use App\DataFixtures\Decision\DecisionFixture; use App\DataFixtures\Decision\MemberFixture; use App\DataFixtures\User\UserFixture; @@ -24,6 +25,7 @@ use App\Entity\Activity\SignupOption; use App\Entity\Activity\UserSignup; use App\Entity\Application\Enums\RevisionStatus; +use App\Entity\Career\Company; use App\Entity\Decision\Member; use App\Entity\Decision\Organ; use App\Entity\User\User; @@ -326,6 +328,7 @@ public function load(ObjectManager $manager): void 'creator' => 8013, 'status' => RevisionStatus::Approved, 'beginTime' => '+2 days 20:00', + 'company' => 'nexunt', 'endTime' => '+2 days 23:00', 'category' => ActivityCategories::Recreational, 'requireGEFLITST' => false, @@ -365,6 +368,7 @@ public function load(ObjectManager $manager): void 'creator' => 8025, 'status' => RevisionStatus::Approved, 'beginTime' => '+2 weeks 19:00', + 'company' => 'nexunt', 'endTime' => '+2 weeks 23:00', 'category' => ActivityCategories::SocialDrink, 'requireGEFLITST' => false, @@ -435,6 +439,7 @@ public function load(ObjectManager $manager): void 'creator' => 8010, 'status' => RevisionStatus::Approved, 'beginTime' => '+1 month 17:00', + 'company' => 'nexunt', 'endTime' => '+1 month 22:00', 'category' => ActivityCategories::Party, 'requireGEFLITST' => true, @@ -485,6 +490,7 @@ public function load(ObjectManager $manager): void 'creator' => 8026, 'status' => RevisionStatus::Approved, 'beginTime' => '+5 weeks 18:00', + 'company' => 'nexunt', 'endTime' => '+5 weeks 23:59', 'category' => ActivityCategories::Conference, 'requireGEFLITST' => true, @@ -834,6 +840,16 @@ public function load(ObjectManager $manager): void $revision->addLabel($this->getReference($labelReference, ActivityLabel::class)); } + // The organising company (a reviewable, display-only field) surfaces on that company's career detail page. + if (isset($data['company'])) { + $revision->setCompany( + $this->getReference( + 'career-company-' . $data['company'], + Company::class, + ), + ); + } + $activity->addRevision($revision); $activity->setCurrentRevision($revision); @@ -1143,7 +1159,7 @@ private function loadWorkflowExamples(ObjectManager $manager): void * endTime: string, * category: ActivityCategories, * ..., - * } $content the activity content; the main loop passes a wider row (with creator, labels, sign-up lists, …) + * } $content the activity content; the main loop passes a wider row (with creator, labels, sign-up lists, ...) */ private function buildRevision( array $content, @@ -1282,6 +1298,8 @@ public function getDependencies(): array UserFixture::class, // The workflow examples are assigned an organising organ, so the organs must be seeded first. DecisionFixture::class, + // Some seeded activities name an organising company, so the companies must be seeded first. + CompanyFixture::class, ]; } } diff --git a/src/DataFixtures/Career/CompanyFixture.php b/src/DataFixtures/Career/CompanyFixture.php new file mode 100644 index 0000000000..26d3edd02d --- /dev/null +++ b/src/DataFixtures/Career/CompanyFixture.php @@ -0,0 +1,381 @@ + */ + private array $labels = []; + + #[Override] + public function load(ObjectManager $manager): void + { + $this->createLabels($manager); + + $this->createCompany( + $manager, + slug: 'nexunt', + name: 'Nexunt Systems', + sloganEn: 'Building the backbone of tomorrow', + sloganNl: 'De ruggengraat van morgen bouwen', + descriptionEn: 'Nexunt Systems designs high-availability infrastructure for research institutions.', + descriptionNl: 'Nexunt Systems ontwerpt hoogbeschikbare infrastructuur voor onderzoeksinstellingen.', + websiteEn: 'https://example.com/nexunt', + websiteNl: 'https://example.com/nexunt', + featured: true, + banner: false, + vacancies: [ + [ + 'slug' => 'backend-engineer', + 'category' => VacancyCategories::Jobs, + 'nameEn' => 'Backend Engineer', + 'nameNl' => 'Backend Engineer', + // Long, multi-paragraph markdown (heading + bullet list) to stress a tall card. + 'descriptionEn' => "## About the role\n\n" + . 'You will design and operate the high-availability services that our research customers ' + . "depend on around the clock. Expect real ownership from day one.\n\n" + . "**What you will do:**\n\n" + . "- Own critical backend services from design through production\n" + . "- Improve our observability, alerting and on-call practices\n" + . "- Mentor interns and junior engineers on the platform team\n\n" + . 'We offer a stable, fully-funded team and plenty of room to grow towards architecture or ' + . 'technical leadership over time.', + 'descriptionNl' => "## Over de functie\n\n" + . 'Je ontwerpt en beheert de hoogbeschikbare diensten waar onze onderzoeksklanten dag en ' + . "nacht op vertrouwen. Vanaf dag één krijg je echte verantwoordelijkheid.\n\n" + . "**Wat je gaat doen:**\n\n" + . "- Kritieke backend-diensten bezitten van ontwerp tot productie\n" + . "- Onze observability, alerting en on-call-praktijken verbeteren\n" + . "- Stagiairs en junior engineers in het platformteam begeleiden\n\n" + . 'We bieden een stabiel, volledig gefinancierd team en ruimte om door te groeien naar ' + . 'architectuur of technisch leiderschap.', + 'labels' => [ + 'fulltime', + 'hybrid', + ], + ], + [ + 'slug' => 'platform-internship', + 'category' => VacancyCategories::Internships, + 'nameEn' => 'Platform Engineering Internship', + 'nameNl' => 'Stage Platform Engineering', + // Deliberately very short, to contrast with the tall card above. + 'descriptionEn' => 'Help us automate our deployment pipeline during a six-month internship.', + 'descriptionNl' => 'Help onze deployment-pipeline te automatiseren in een stage van zes maanden.', + 'labels' => ['hybrid'], + ], + ], + ); + + $this->createCompany( + $manager, + slug: 'orbit-analytics', + name: 'Orbit Analytics', + sloganEn: 'Turning data into decisions', + sloganNl: 'Van data naar beslissingen', + descriptionEn: 'Orbit Analytics is a data science consultancy for ambitious scale-ups.', + descriptionNl: 'Orbit Analytics is een data science consultancy voor ambitieuze scale-ups.', + websiteEn: 'https://example.com/orbit', + websiteNl: 'https://example.com/orbit', + featured: false, + banner: true, + vacancies: [ + [ + 'slug' => 'data-science-internship', + 'category' => VacancyCategories::Internships, + 'nameEn' => 'Data Science Internship', + 'nameNl' => 'Stage Data Science', + // Medium: a single solid paragraph. + 'descriptionEn' => 'Join our consultancy team for a semester and work on real client data. You ' + . 'will build models, present findings to stakeholders, and learn how data science delivers ' + . 'value in practice. A compensation and a laptop are provided.', + 'descriptionNl' => 'Kom een semester bij ons consultancyteam en werk met echte klantdata. Je ' + . 'bouwt modellen, presenteert bevindingen aan stakeholders en leert hoe data science in de ' + . 'praktijk waarde levert. Een vergoeding en laptop worden geregeld.', + 'labels' => ['remote'], + ], + [ + 'slug' => 'ml-thesis', + 'category' => VacancyCategories::ThesisProjects, + 'nameEn' => 'Master Thesis: Explainable ML', + 'nameNl' => 'Afstudeerproject: Verklaarbare ML', + // Long-ish single paragraph (no markdown structure). + 'descriptionEn' => 'We are looking for a graduate student to research explainability methods for ' + . 'the models we deploy in production. You will have access to anonymised datasets, a ' + . 'dedicated supervisor, and the opportunity to publish your results at the end of the ' + . 'project. Strong Python skills and a background in machine learning are expected.', + 'descriptionNl' => 'We zoeken een afstudeerder om verklaarbaarheidsmethoden te onderzoeken voor de ' + . 'modellen die wij in productie draaien. Je krijgt toegang tot geanonimiseerde datasets, een ' + . 'vaste begeleider en de kans om je resultaten aan het eind te publiceren. Sterke ' + . 'Python-vaardigheden en een achtergrond in machine learning zijn vereist.', + 'labels' => [], + ], + ], + ); + + $this->createCompany( + $manager, + slug: 'delta-robotics', + name: 'Delta Robotics', + sloganEn: 'Automation with a human touch', + sloganNl: 'Automatisering met een menselijke maat', + descriptionEn: 'Delta Robotics builds collaborative robots for small manufacturers across the country.', + descriptionNl: 'Delta Robotics bouwt samenwerkende robots voor kleine fabrikanten door het hele land.', + websiteEn: 'https://example.com/delta', + websiteNl: 'https://example.com/delta', + featured: false, + banner: false, + vacancies: [ + [ + 'slug' => 'robotics-traineeship', + 'category' => VacancyCategories::Traineeships, + 'nameEn' => 'Robotics Traineeship', + 'nameNl' => 'Traineeship Robotica', + // Medium paragraph. + 'descriptionEn' => 'A two-year traineeship rotating across our mechanical, software and controls ' + . 'teams. Ideal for graduates who want broad exposure before specialising.', + 'descriptionNl' => 'Een tweejarig traineeship langs onze mechanische, software- en controls-teams. ' + . 'Ideaal voor afgestudeerden die eerst breed willen kijken voordat ze zich specialiseren.', + 'labels' => ['fulltime'], + ], + [ + 'slug' => 'student-assistant', + 'category' => VacancyCategories::StudentJobs, + 'nameEn' => 'Student Assistant Robotics Lab', + 'nameNl' => 'Studentassistent Roboticalab', + // Very short. + 'descriptionEn' => 'Support our lab a few hours per week alongside your studies.', + 'descriptionNl' => 'Ondersteun ons lab een paar uur per week naast je studie.', + 'labels' => ['remote'], + ], + [ + 'slug' => 'controls-engineer', + 'category' => VacancyCategories::Jobs, + 'nameEn' => 'Controls Engineer', + 'nameNl' => 'Controls Engineer', + // Medium with an inline bullet list. + 'descriptionEn' => 'Design and tune the motion control that makes our cobots safe to work ' + . "alongside.\n\n" + . "- Develop real-time control loops\n" + . "- Validate safety behaviour on the shop floor\n" + . '- Work directly with our manufacturing customers', + 'descriptionNl' => 'Ontwerp en stem de bewegingsbesturing af die onze cobots veilig maakt om mee ' + . "samen te werken.\n\n" + . "- Realtime regelkringen ontwikkelen\n" + . "- Veiligheidsgedrag op de werkvloer valideren\n" + . '- Direct samenwerken met onze productieklanten', + 'labels' => [ + 'fulltime', + 'hybrid', + ], + ], + ], + ); + + $manager->flush(); + } + + private function createLabels(ObjectManager $manager): void + { + $definitions = [ + 'fulltime' => [ + 'en' => 'Full-time', + 'nl' => 'Fulltime', + 'abbr' => 'FT', + ], + 'hybrid' => [ + 'en' => 'Hybrid', + 'nl' => 'Hybride', + 'abbr' => 'HYB', + ], + 'remote' => [ + 'en' => 'Remote', + 'nl' => 'Op afstand', + 'abbr' => 'REM', + ], + ]; + + foreach ($definitions as $key => $definition) { + $label = new VacancyLabel(); + $label->setName(new CareerLocalisedText($definition['en'], $definition['nl'])); + $label->setAbbreviation(new CareerLocalisedText($definition['abbr'], $definition['abbr'])); + $manager->persist($label); + + $this->labels[$key] = $label; + } + } + + /** + * @param VacancyData[] $vacancies + */ + private function createCompany( + ObjectManager $manager, + string $slug, + string $name, + string $sloganEn, + string $sloganNl, + string $descriptionEn, + string $descriptionNl, + string $websiteEn, + string $websiteNl, + bool $featured, + bool $banner, + array $vacancies, + ): void { + $company = new Company(); + $company->setName($name); + $company->setSlugName($slug); + $company->setRepresentativeName('Recruitment ' . $name); + $company->setRepresentativeEmail('recruitment@' . $slug . '.example.com'); + $company->setPublished(true); + + $revision = new CompanyRevision(); + $revision->setStatus(RevisionStatus::Approved); + $revision->setRevisionNumber(1); + $revision->setSlogan(new CareerLocalisedText($sloganEn, $sloganNl)); + $revision->setDescription(new CareerLocalisedText($descriptionEn, $descriptionNl)); + $revision->setWebsite(new CareerLocalisedText($websiteEn, $websiteNl)); + $revision->setContactName('Recruitment Team'); + $revision->setContactEmail('recruitment@' . $slug . '.example.com'); + $revision->setContactPhone('+31 40 000 0000'); + + $company->addRevision($revision); + $company->setCurrentRevision($revision); + $company->setLiveRevision($revision); + + // Exposed so other fixtures (e.g. activities organised by a company) can reference it. + $this->addReference( + 'career-company-' . $slug, + $company, + ); + + $jobPackage = new CompanyJobPackage(); + $this->configurePackage( + $jobPackage, + $company, + ); + + foreach ($vacancies as $data) { + $vacancy = $this->createVacancy( + $jobPackage, + $data, + ); + $manager->persist($vacancy); + } + + $manager->persist($company); + $manager->persist($revision); + $manager->persist($jobPackage); + + if ($banner) { + $bannerPackage = new CompanyBannerPackage(); + $this->configurePackage( + $bannerPackage, + $company, + ); + $bannerPackage->setImage('data/company/banner/' . $slug . '.png'); + $manager->persist($bannerPackage); + } + + if (!$featured) { + return; + } + + $featuredPackage = new CompanyFeaturedPackage(); + $this->configurePackage( + $featuredPackage, + $company, + ); + $featuredPackage->setArticle(new CareerLocalisedText( + 'We sat down with ' . $name . ' to talk about the projects our members could work on.', + 'We spraken met ' . $name . ' over de projecten waaraan onze leden kunnen werken.', + )); + $manager->persist($featuredPackage); + } + + /** + * Makes a package active: started in the past, expiring far in the future, and published. + */ + private function configurePackage( + CompanyPackage $package, + Company $company, + ): void { + $package->setCompany($company); + $package->setStartingDate(new DateTime('2020-01-01')); + $package->setExpirationDate(new DateTime('2100-01-01')); + $package->setPublished(true); + } + + /** + * @param VacancyData $data + */ + private function createVacancy( + CompanyJobPackage $package, + array $data, + ): Vacancy { + $vacancy = new Vacancy(); + $vacancy->setSlugName($data['slug']); + $vacancy->setPublished(true); + $vacancy->setPackage($package); + $package->addVacancy($vacancy); + + $revision = new VacancyRevision(); + $revision->setStatus(RevisionStatus::Approved); + $revision->setRevisionNumber(1); + $revision->setName(new CareerLocalisedText($data['nameEn'], $data['nameNl'])); + $revision->setLocation(new CareerLocalisedText('Eindhoven', 'Eindhoven')); + $revision->setWebsite(new CareerLocalisedText( + 'https://example.com/' . $data['slug'], + 'https://example.com/' . $data['slug'], + )); + $revision->setDescription(new CareerLocalisedText( + $data['descriptionEn'], + $data['descriptionNl'], + )); + $revision->setAttachment(new CareerLocalisedText('', '')); + $revision->setCategory($data['category']); + + foreach ($data['labels'] as $labelKey) { + $revision->addLabel($this->labels[$labelKey]); + } + + $vacancy->addRevision($revision); + $vacancy->setCurrentRevision($revision); + $vacancy->setLiveRevision($revision); + + return $vacancy; + } +} diff --git a/src/Entity/Career/Company.php b/src/Entity/Career/Company.php index 8f404c0333..58c2a17bed 100644 --- a/src/Entity/Career/Company.php +++ b/src/Entity/Career/Company.php @@ -9,7 +9,7 @@ use App\Entity\Application\Traits\IdentifiableTrait; use App\Entity\Application\Traits\TimestampableTrait; use App\Entity\Career\Enums\CompanyPackageTypes; -use App\Entity\Career\VacancyCategory as VacancyCategoryModel; +use App\Entity\Career\Enums\VacancyCategories; use App\Entity\Decision\Member as MemberModel; use App\Entity\Decision\Organ as OrganModel; use App\Repository\Career\CompanyRepository; @@ -404,7 +404,7 @@ public function getNumberOfJobs(): int * Returns the number of jobs that are contained in all active packages of this * company. */ - public function getNumberOfActiveJobs(?VacancyCategoryModel $category = null): int + public function getNumberOfActiveJobs(?VacancyCategories $category = null): int { $jobCount = static function (CompanyPackage $package) use ($category): int { if ($package instanceof CompanyJobPackage) { @@ -417,6 +417,29 @@ public function getNumberOfActiveJobs(?VacancyCategoryModel $category = null): i return array_sum(array_map($jobCount, $this->getPackages()->toArray())); } + /** + * Returns the active vacancies of this company, optionally limited to a single category, gathered across all of the + * company's job packages. + * + * @return Vacancy[] + */ + public function getActiveVacancies(?VacancyCategories $category = null): array + { + $vacancies = []; + + foreach ($this->getPackages() as $package) { + if (!$package instanceof CompanyJobPackage) { + continue; + } + + foreach ($package->getJobsInCategory($category) as $vacancy) { + $vacancies[] = $vacancy; + } + } + + return $vacancies; + } + /** * Returns the number of expired packages. */ diff --git a/src/Entity/Career/CompanyJobPackage.php b/src/Entity/Career/CompanyJobPackage.php index 06c142965f..7d58476189 100644 --- a/src/Entity/Career/CompanyJobPackage.php +++ b/src/Entity/Career/CompanyJobPackage.php @@ -5,7 +5,7 @@ namespace App\Entity\Career; use App\Entity\Career\Enums\CompanyPackageTypes; -use App\Entity\Career\VacancyCategory as VacancyCategoryModel; +use App\Entity\Career\Enums\VacancyCategories; use App\Repository\Career\CompanyJobPackageRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -61,7 +61,7 @@ public function getVacancies(): Collection * * @return int of vacancies in the package */ - public function getNumberOfActiveJobs(?VacancyCategoryModel $category = null): int + public function getNumberOfActiveJobs(?VacancyCategories $category = null): int { return count($this->getJobsInCategory($category)); } @@ -71,7 +71,7 @@ public function getNumberOfActiveJobs(?VacancyCategoryModel $category = null): i * * @return Vacancy[] */ - public function getJobsInCategory(?VacancyCategoryModel $category = null): array + public function getJobsInCategory(?VacancyCategories $category = null): array { $filter = static function (Vacancy $vacancy) use ($category): bool { if (null === $category) { diff --git a/src/Entity/Career/Enums/VacancyCategories.php b/src/Entity/Career/Enums/VacancyCategories.php new file mode 100644 index 0000000000..ec6494da53 --- /dev/null +++ b/src/Entity/Career/Enums/VacancyCategories.php @@ -0,0 +1,66 @@ + new TranslatableMessage('Job'), + self::Internships => new TranslatableMessage('Internship'), + self::Traineeships => new TranslatableMessage('Traineeship'), + self::StudentJobs => new TranslatableMessage('Student job'), + self::ThesisProjects => new TranslatableMessage('Thesis project'), + }; + } + + /** + * The plural, human-readable name of the category (e.g. shown in the menu and as a listing heading). + */ + public function pluralLabel(): TranslatableMessage + { + return match ($this) { + self::Jobs => new TranslatableMessage('Jobs'), + self::Internships => new TranslatableMessage('Internships'), + self::Traineeships => new TranslatableMessage('Traineeships'), + self::StudentJobs => new TranslatableMessage('Student jobs'), + self::ThesisProjects => new TranslatableMessage('Thesis projects'), + }; + } + + /** + * The `.badge-*` modifier used to colour this category's badge. Each category gets its own hue (mapped onto the + * existing subtle theme-colour badges, so both light and dark modes are handled) so the categories are visually + * distinguishable at a glance across the vacancy listings and detail pages. + */ + public function badgeClass(): string + { + return match ($this) { + self::Jobs => 'badge-gewis-primary', + self::Internships => 'badge-info', + self::Traineeships => 'badge-success', + self::StudentJobs => 'badge-warning', + self::ThesisProjects => 'badge-secondary', + }; + } +} diff --git a/src/Entity/Career/Vacancy.php b/src/Entity/Career/Vacancy.php index 736450565d..c436bbe059 100644 --- a/src/Entity/Career/Vacancy.php +++ b/src/Entity/Career/Vacancy.php @@ -8,6 +8,7 @@ use App\Entity\Application\RevisionInterface; use App\Entity\Application\Traits\IdentifiableTrait; use App\Entity\Application\Traits\TimestampableTrait; +use App\Entity\Career\Enums\VacancyCategories; use App\Entity\Decision\Member as MemberModel; use App\Entity\Decision\Organ as OrganModel; use App\Repository\Career\VacancyRepository; @@ -312,7 +313,7 @@ public function getContactEmail(): ?string /** * Get the vacancy's category. */ - public function getCategory(): VacancyCategory + public function getCategory(): VacancyCategories { return $this->getDisplayRevision()->getCategory(); } @@ -356,7 +357,7 @@ public function getResourceCompany(): ?Company /** * @return array{ * slugName: string, - * category: VacancyCategory, + * category: string, * contactName: ?string, * contactEmail: ?string, * contactPhone: ?string, @@ -383,7 +384,7 @@ public function toArray(): array return [ 'slugName' => $this->getSlugName(), - 'category' => $this->getCategory(), + 'category' => $this->getCategory()->value, 'contactName' => $this->getContactName(), 'contactEmail' => $this->getContactEmail(), 'contactPhone' => $this->getContactPhone(), diff --git a/src/Entity/Career/VacancyCategory.php b/src/Entity/Career/VacancyCategory.php deleted file mode 100644 index 782c9aa4dc..0000000000 --- a/src/Entity/Career/VacancyCategory.php +++ /dev/null @@ -1,167 +0,0 @@ -hidden; - } - - /** - * Set's the id. - */ - public function setHidden(bool $hidden): void - { - $this->hidden = $hidden; - } - - /** - * Gets the name. - */ - public function getName(): CareerLocalisedText - { - return $this->name; - } - - /** - * Sets the name. - */ - public function setName(CareerLocalisedText $name): void - { - $this->name = $name; - } - - /** - * Gets the plural name. - */ - public function getPluralName(): CareerLocalisedText - { - return $this->pluralName; - } - - /** - * Sets the name. - */ - public function setPluralName(CareerLocalisedText $name): void - { - $this->pluralName = $name; - } - - /** - * Gets the slug. - */ - public function getSlug(): CareerLocalisedText - { - return $this->slug; - } - - /** - * Sets the slug. - */ - public function setSlug(CareerLocalisedText $slug): void - { - $this->slug = $slug; - } - - /** - * @return array{ - * name: ?string, - * nameEn: ?string, - * pluralName: ?string, - * pluralNameEn: ?string, - * slug: ?string, - * slugEn: ?string, - * hidden: bool, - * } - */ - public function toArray(): array - { - return [ - 'name' => $this->getName()->getValueNL(), - 'nameEn' => $this->getName()->getValueEN(), - 'pluralName' => $this->getPluralName()->getValueNL(), - 'pluralNameEn' => $this->getPluralName()->getValueEN(), - 'slug' => $this->getSlug()->getValueNL(), - 'slugEn' => $this->getSlug()->getValueEN(), - 'hidden' => $this->getHidden(), - ]; - } -} diff --git a/src/Entity/Career/VacancyRevision.php b/src/Entity/Career/VacancyRevision.php index 5a0fe47482..41ec7fcd9d 100644 --- a/src/Entity/Career/VacancyRevision.php +++ b/src/Entity/Career/VacancyRevision.php @@ -6,6 +6,7 @@ use App\Entity\Application\AbstractRevision; use App\Entity\Application\RevisableInterface; +use App\Entity\Career\Enums\VacancyCategories; use App\Repository\Career\VacancyRevisionRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -140,13 +141,11 @@ class VacancyRevision extends AbstractRevision )] private ?string $contactEmail = null; - #[ManyToOne(targetEntity: VacancyCategory::class)] - #[JoinColumn( - name: 'category_id', - referencedColumnName: 'id', - nullable: false, + #[Column( + type: Types::STRING, + enumType: VacancyCategories::class, )] - private VacancyCategory $category; + private VacancyCategories $category; /** * The labels of this revision of the vacancy. Each revision owns its own assignments (carried forward when a draft @@ -322,12 +321,12 @@ public function setContactEmail(?string $contactEmail): void $this->contactEmail = $contactEmail; } - public function getCategory(): VacancyCategory + public function getCategory(): VacancyCategories { return $this->category; } - public function setCategory(VacancyCategory $category): void + public function setCategory(VacancyCategories $category): void { $this->category = $category; } diff --git a/src/Form/DisablesFieldsTrait.php b/src/Form/DisablesFieldsTrait.php index e4d4458cba..8d49bee2ba 100644 --- a/src/Form/DisablesFieldsTrait.php +++ b/src/Form/DisablesFieldsTrait.php @@ -8,7 +8,7 @@ /** * Shared helper for form types that render a field read-only based on runtime state (the activity has started, the - * revision is frozen, …). Symfony has no in-place "disable"; re-adding the field with the `disabled` option flipped, + * revision is frozen, ...). Symfony has no in-place "disable"; re-adding the field with the `disabled` option flipped, * preserving its type and options, is the supported way, and a disabled field is ignored on submit. */ trait DisablesFieldsTrait diff --git a/src/Repository/Activity/ActivityRepository.php b/src/Repository/Activity/ActivityRepository.php index 96b257ef5b..f38adbd54c 100644 --- a/src/Repository/Activity/ActivityRepository.php +++ b/src/Repository/Activity/ActivityRepository.php @@ -9,6 +9,7 @@ use App\Entity\Activity\SignupList; use App\Entity\Activity\UserSignup; use App\Entity\Application\Enums\RevisionStatus; +use App\Entity\Career\Company; use App\Entity\Decision\AssociationYear; use App\Entity\Decision\Member; use App\Entity\Decision\Organ; @@ -491,6 +492,50 @@ public function findForOverview( return $paginator; } + /** + * Find a company's upcoming, publicly visible activities — the ones whose live (approved) revision names this + * company as its organiser — soonest first. Used by the company detail page's activities panel. + * + * @return Activity[] + */ + public function findUpcomingByCompany( + Company $company, + int $limit, + ): array { + return $this->createQueryBuilder('a') + ->addSelect( + 'lr', + 'n', + ) + ->join( + 'a.liveRevision', + 'lr', + ) + ->join( + 'lr.name', + 'n', + ) + ->andWhere('a.unpublishedAt IS NULL') + ->andWhere('IDENTITY(lr.company) = :companyId') + ->andWhere('lr.endTime > :now') + ->setParameter( + 'companyId', + $company->getId(), + ) + ->setParameter( + 'now', + new DateTime(), + Types::DATETIME_MUTABLE, + ) + ->orderBy( + 'lr.beginTime', + 'ASC', + ) + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + /** * Eager-loads the live revision's sign-up lists for the given activities in a single query, hydrating each * activity's (otherwise lazy) live-revision `signupLists` collection. This avoids the N+1 that the overview's diff --git a/src/Repository/Career/CompanyRepository.php b/src/Repository/Career/CompanyRepository.php index f1e8ce4067..9522e5c498 100644 --- a/src/Repository/Career/CompanyRepository.php +++ b/src/Repository/Career/CompanyRepository.php @@ -5,6 +5,7 @@ namespace App\Repository\Career; use App\Entity\Career\Company; +use App\Entity\Career\CompanyJobPackage; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -60,10 +61,106 @@ public function findAllPublic(): array ORDER BY `t1`.`name` ASC QUERY; - return $this->getEntityManager()->createNativeQuery( + $companies = $this->getEntityManager()->createNativeQuery( $sql, $rsmBuilder, )->getResult(); + + if ([] === $companies) { + return []; + } + + $this->warmOverviewAssociations($companies); + + return $companies; + } + + /** + * Eagerly loads everything the company overview renders onto the given (already managed) companies, so the page + * does not fan out into a lazy load per association. + * + * The overview reads each company's live revision and its localised texts (slogan, description, website), plus the + * active vacancies grouped per package. Since every {@see CareerLocalisedText} lives in its own row, lazy loading + * them is the dominant source of the N+1 explosion. Two fetch-joining queries warm the identity map instead: one + * for the companies (a single to-many join on the packages) and one for the vacancies (whose collection lives on + * the {@see CompanyJobPackage} subclass and so cannot be joined through the base package in the same query). The + * results are discarded; hydrating them populates the associations on the managed instances passed in. + * + * @param Company[] $companies + */ + private function warmOverviewAssociations(array $companies): void + { + $this->createQueryBuilder('c') + ->addSelect( + 'liveRevision', + 'slogan', + 'description', + 'website', + 'package', + ) + ->join( + 'c.liveRevision', + 'liveRevision', + ) + ->join( + 'liveRevision.slogan', + 'slogan', + ) + ->join( + 'liveRevision.description', + 'description', + ) + ->join( + 'liveRevision.website', + 'website', + ) + ->leftJoin( + 'c.packages', + 'package', + ) + ->where('c IN (:companies)') + ->setParameter( + 'companies', + $companies, + ) + ->getQuery() + ->getResult(); + + $this->getEntityManager()->createQueryBuilder() + ->select( + 'package', + 'vacancy', + 'liveRevision', + 'name', + 'website', + ) + ->from( + CompanyJobPackage::class, + 'package', + ) + ->leftJoin( + 'package.vacancies', + 'vacancy', + ) + ->leftJoin( + 'vacancy.liveRevision', + 'liveRevision', + ) + ->leftJoin( + 'liveRevision.name', + 'name', + ) + ->leftJoin( + 'liveRevision.website', + 'website', + ) + ->where('package.company IN (:companies)') + ->setParameter( + 'companies', + $companies, + ) + ->getQuery() + ->getResult(); } /** @@ -76,6 +173,26 @@ public function findCompanyBySlugName(string $slugName): ?Company return $this->findOneBy(['slugName' => $slugName]); } + /** + * Return the publicly visible company with the given slug, or null when it does not exist or is hidden. The + * associations the detail page renders are warmed up front to avoid a lazy load per localised text. + */ + public function findPublicCompanyBySlugName(string $slugName): ?Company + { + $company = $this->findCompanyBySlugName($slugName); + + if ( + null === $company + || $company->isHidden() + ) { + return null; + } + + $this->warmOverviewAssociations([$company]); + + return $company; + } + /** * Return a company by a given representative's email address. */ diff --git a/src/Repository/Career/VacancyCategoryRepository.php b/src/Repository/Career/VacancyCategoryRepository.php deleted file mode 100644 index f6deb4945a..0000000000 --- a/src/Repository/Career/VacancyCategoryRepository.php +++ /dev/null @@ -1,76 +0,0 @@ - - */ -class VacancyCategoryRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct( - $registry, - VacancyCategory::class, - ); - } - - /** - * @return VacancyCategory[] - */ - public function findVisibleCategories(): array - { - $qb = $this->createQueryBuilder('c'); - $qb->where('c.hidden = :hidden') - ->setParameter( - 'hidden', - false, - ); - - return $qb->getQuery()->getResult(); - } - - /** - * Searches for a VacancyCategory based on its slug. The value is always converted to lowercase to ensure no weird - * routing issues occur. - * - * @throws NonUniqueResultException - */ - public function findCategoryBySlug(string $value): ?VacancyCategory - { - $qb = $this->createQueryBuilder('c'); - $qb->innerJoin( - 'c.slug', - 'loc', - Join::WITH, - $qb->expr()->orX( - 'LOWER(loc.valueEN) = :value', - 'LOWER(loc.valueNL) = :value', - ), - ) - ->setParameter( - ':value', - strtolower($value), - ); - - return $qb->getQuery()->getOneOrNullResult(); - } - - public function findVisibleCategoryById(int $id): ?VacancyCategory - { - return $this->findOneBy([ - 'id' => $id, - 'hidden' => false, - ]); - } -} diff --git a/src/Repository/Career/VacancyRepository.php b/src/Repository/Career/VacancyRepository.php index eb909cbd62..3d5b6e83c2 100644 --- a/src/Repository/Career/VacancyRepository.php +++ b/src/Repository/Career/VacancyRepository.php @@ -4,11 +4,18 @@ namespace App\Repository\Career; +use App\Entity\Career\Enums\VacancyCategories; use App\Entity\Career\Vacancy; +use App\Entity\Career\VacancyLabel; +use DateTime; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; -use Doctrine\ORM\Query\Expr\Join; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; +use function mb_strtolower; +use function trim; + /** * @extends ServiceEntityRepository */ @@ -33,7 +40,7 @@ public function __construct(ManagerRegistry $registry) public function isSlugNameUnique( string $companySlugName, string $vacancySlugName, - int $vacancyCategoryId, + VacancyCategories $category, ): bool { $count = $this->createQueryBuilder('v') ->select('COUNT(v.id)') @@ -51,7 +58,7 @@ public function isSlugNameUnique( ) ->where('c.slugName = :companySlugName') ->andWhere('v.slugName = :vacancySlugName') - ->andWhere('IDENTITY(cr.category) = :vacancyCategoryId') + ->andWhere('cr.category = :category') ->setParameter( 'companySlugName', $companySlugName, @@ -61,8 +68,8 @@ public function isSlugNameUnique( $vacancySlugName, ) ->setParameter( - 'vacancyCategoryId', - $vacancyCategoryId, + 'category', + $category->value, ) ->getQuery() ->getSingleScalarResult(); @@ -79,8 +86,7 @@ public function isSlugNameUnique( * @return Vacancy[] */ public function findVacancy( - ?int $vacancyCategoryId = null, - ?string $vacancyCategorySlug = null, + ?VacancyCategories $category = null, ?int $vacancyLabelId = null, ?string $vacancySlugName = null, ?string $companySlugName = null, @@ -102,33 +108,11 @@ public function findVacancy( ) ->addSelect('lr'); - if (null !== $vacancyCategoryId) { - $qb->join( - 'lr.category', - 'cat', - ) - ->andWhere('cat.id = :vacancyCategoryId') - ->setParameter( - 'vacancyCategoryId', - $vacancyCategoryId, - ); - } elseif (null !== $vacancyCategorySlug) { - $qb->innerJoin( - 'lr.category', - 'cat', - ) - ->innerJoin( - 'cat.slug', - 'loc', - Join::WITH, - $qb->expr()->orX( - 'LOWER(loc.valueEN) = :vacancyCategorySlug', - 'LOWER(loc.valueNL) = :vacancyCategorySlug', - ), - ) + if (null !== $category) { + $qb->andWhere('lr.category = :category') ->setParameter( - 'vacancyCategorySlug', - $vacancyCategorySlug, + 'category', + $category->value, ); } @@ -163,6 +147,186 @@ public function findVacancy( return $qb->getQuery()->getResult(); } + /** + * Find the publicly visible vacancies for the public overview, narrowed by the optional filters (category, owning + * company, assigned labels and a free-text search over the localised name). + * + * This expresses the full "active" predicate ({@see Vacancy::isActive()}) in the query so the filters apply at the + * database level: the vacancy and its package must be published and the package within its active window, and the + * owning company must be published with an approved revision (an active package implies the company has a + * non-expired one, so {@see \App\Entity\Career\Company::isHidden()} reduces to those two checks here). Every + * association the card renders is fetch-joined to keep the page free of per-item lazy loads. + * + * @param int[] $labelIds + * + * @return Vacancy[] + */ + public function findForOverview( + ?VacancyCategories $category = null, + ?string $companySlugName = null, + array $labelIds = [], + string $search = '', + ): array { + $qb = $this->activeVacancyQueryBuilder() + ->orderBy( + 'c.name', + 'ASC', + ) + ->addOrderBy( + 'j.id', + 'ASC', + ); + + if (null !== $category) { + $qb->andWhere('lr.category = :category') + ->setParameter( + 'category', + $category->value, + ); + } + + if (null !== $companySlugName) { + $qb->andWhere('c.slugName = :companySlugName') + ->setParameter( + 'companySlugName', + $companySlugName, + ); + } + + if ([] !== $labelIds) { + // Filter through an EXISTS subquery rather than a selected join, so the vacancy's own labels collection is + // still hydrated in full for display (a filtering join would prune it to the matched labels). + $subQuery = $this->getEntityManager()->createQueryBuilder() + ->select('1') + ->from( + VacancyLabel::class, + 'filterLabel', + ) + ->join( + 'filterLabel.revisions', + 'filterRevision', + ) + ->where('filterRevision = lr') + ->andWhere('filterLabel.id IN (:labelIds)'); + + $qb->andWhere($qb->expr()->exists($subQuery->getDQL())) + ->setParameter( + 'labelIds', + $labelIds, + ); + } + + if ('' !== trim($search)) { + $qb->andWhere('LOWER(name.valueEN) LIKE :search OR LOWER(name.valueNL) LIKE :search') + ->setParameter( + 'search', + '%' . mb_strtolower(trim($search)) . '%', + ); + } + + return $qb->getQuery()->getResult(); + } + + /** + * Find a single publicly visible vacancy by its company, category and slug (the tuple that identifies it in a URL), + * or null when it does not exist or is not currently active. Shares the "active" predicate and the fetch joins with + * {@see self::findForOverview()}, so the detail page renders without lazy loads. + */ + public function findPublicVacancy( + string $companySlugName, + VacancyCategories $category, + string $vacancySlugName, + ): ?Vacancy { + $result = $this->activeVacancyQueryBuilder() + ->andWhere('c.slugName = :companySlugName') + ->andWhere('lr.category = :category') + ->andWhere('j.slugName = :vacancySlugName') + ->setParameter( + 'companySlugName', + $companySlugName, + ) + ->setParameter( + 'category', + $category->value, + ) + ->setParameter( + 'vacancySlugName', + $vacancySlugName, + ) + ->getQuery() + ->getResult(); + + return $result[0] ?? null; + } + + /** + * The base query for publicly visible ("active") vacancies, with every association the cards and the detail page + * render fetch-joined. Expresses {@see Vacancy::isActive()} at the database level: the vacancy and its package must + * be published and the package within its active window, and the owning company published with an approved revision + * (an active package implies a non-expired one, so {@see \App\Entity\Career\Company::isHidden()} reduces to those + * two checks here). Callers add their own filters and ordering. + */ + private function activeVacancyQueryBuilder(): QueryBuilder + { + return $this->createQueryBuilder('j') + ->join( + 'j.package', + 'p', + ) + ->addSelect('p') + ->join( + 'p.company', + 'c', + ) + ->addSelect('c') + ->join( + 'j.liveRevision', + 'lr', + ) + ->addSelect('lr') + ->join( + 'lr.name', + 'name', + ) + ->addSelect('name') + ->join( + 'lr.location', + 'location', + ) + ->addSelect('location') + ->join( + 'lr.description', + 'description', + ) + ->addSelect('description') + ->join( + 'lr.website', + 'website', + ) + ->addSelect('website') + ->leftJoin( + 'lr.labels', + 'label', + ) + ->addSelect('label') + ->leftJoin( + 'label.name', + 'labelName', + ) + ->addSelect('labelName') + ->where('j.published = true') + ->andWhere('p.published = true') + ->andWhere('p.starts <= :now') + ->andWhere('p.expires > :now') + ->andWhere('c.published = true') + ->andWhere('c.liveRevision IS NOT NULL') + ->setParameter( + 'now', + new DateTime(), + Types::DATETIME_MUTABLE, + ); + } + public function findByPackageAndCompany( string $companySlugName, int $packageId, diff --git a/src/Twig/Components/Career/VacancyOverview.php b/src/Twig/Components/Career/VacancyOverview.php new file mode 100644 index 0000000000..de42589904 --- /dev/null +++ b/src/Twig/Components/Career/VacancyOverview.php @@ -0,0 +1,167 @@ +requestStack->getCurrentRequest(); + if ( + null === $request + || !$request->query->has('labels') + ) { + return; + } + + $raw = $request->query->all()['labels']; + $values = is_array($raw) + ? $raw + : explode( + ',', + strval($raw), + ); + + $this->labelFilters = self::positiveIntIds($values); + } + + /** + * @return Vacancy[] + */ + public function getVacancies(): array + { + return $this->vacancies ??= $this->vacancyRepository->findForOverview( + category: null !== $this->category + ? VacancyCategories::tryFrom($this->category) + : null, + companySlugName: '' !== (string) $this->companyFilter + ? $this->companyFilter + : null, + labelIds: self::positiveIntIds($this->labelFilters), + search: $this->search, + ); + } + + /** + * @return VacancyCategories[] + */ + public function getCategories(): array + { + return VacancyCategories::cases(); + } + + /** + * @return Company[] + */ + public function getCompanies(): array + { + return $this->companyRepository->findAllPublic(); + } + + /** + * @return VacancyLabel[] + */ + public function getLabels(): array + { + return $this->vacancyLabelRepository->findBy( + [], + ['id' => 'ASC'], + ); + } + + /** + * Normalise a raw list of label-id values into a clean, re-indexed list of positive ints (dropping blanks, zero and + * negatives). Shared by mount() (query-string parsing) and getVacancies() (filtering) so the two can never drift. + * + * @param array $values + * + * @return int[] + */ + private static function positiveIntIds(array $values): array + { + return array_values( + array_filter( + array_map( + 'intval', + $values, + ), + static fn (int $id): bool => $id > 0, + ), + ); + } +} diff --git a/src/Twig/Extensions/ActivityDateRangeExtension.php b/src/Twig/Extensions/ActivityDateRangeExtension.php index 71567529ea..23be06f8b3 100644 --- a/src/Twig/Extensions/ActivityDateRangeExtension.php +++ b/src/Twig/Extensions/ActivityDateRangeExtension.php @@ -19,7 +19,7 @@ * Formats an activity's begin/end as a single locale-aware range string, mirroring the previous GEWIS overview: * - single day: "Thursday 28 May. 12:40 - 13:20" * - multiple days: "Sun. 17 May. (00:00) - Sun. 21 Jun. (23:59)" - * A side that falls in a different calendar year than today also shows its year (e.g. "… 14 Dec. 2026 …"). + * A side that falls in a different calendar year than today also shows its year (e.g. "... 14 Dec. 2026 ..."). */ class ActivityDateRangeExtension extends AbstractExtension { diff --git a/src/Twig/Extensions/CareerExtension.php b/src/Twig/Extensions/CareerExtension.php index aa49b9d3f5..f4959e27ca 100644 --- a/src/Twig/Extensions/CareerExtension.php +++ b/src/Twig/Extensions/CareerExtension.php @@ -5,9 +5,8 @@ namespace App\Twig\Extensions; use App\Entity\Career\CompanyFeaturedPackage; -use App\Entity\Career\VacancyCategory; +use App\Entity\Career\Enums\VacancyCategories; use App\Repository\Career\CompanyFeaturedPackageRepository; -use App\Repository\Career\VacancyCategoryRepository; use Override; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -16,7 +15,6 @@ class CareerExtension extends AbstractExtension { public function __construct( private readonly CompanyFeaturedPackageRepository $companyFeaturedPackageRepository, - private readonly VacancyCategoryRepository $vacancyCategoryRepository, ) { } @@ -44,10 +42,10 @@ public function getFeaturedCompany(): ?CompanyFeaturedPackage } /** - * @return VacancyCategory[] + * @return VacancyCategories[] */ public function getVacancyCategories(): array { - return $this->vacancyCategoryRepository->findVisibleCategories(); + return VacancyCategories::cases(); } } diff --git a/src/Util/Application/SplitToken.php b/src/Util/Application/SplitToken.php index b775765e39..28e693e8d1 100644 --- a/src/Util/Application/SplitToken.php +++ b/src/Util/Application/SplitToken.php @@ -31,7 +31,7 @@ public static function generate( int $verifierBytes, string $hashAlgo, ): array { - // max(1, …) keeps random_bytes' length strictly positive (and satisfies its int<1, max> contract). + // max(1, ...) keeps random_bytes' length strictly positive (and satisfies its int<1, max> contract). $selector = bin2hex(random_bytes(max(1, $selectorBytes))); $verifier = bin2hex(random_bytes(max(1, $verifierBytes))); diff --git a/templates/activity/admin/approvals/review.html.twig b/templates/activity/admin/approvals/review.html.twig index 687763c93a..a6e14ed26f 100644 --- a/templates/activity/admin/approvals/review.html.twig +++ b/templates/activity/admin/approvals/review.html.twig @@ -76,7 +76,7 @@ {% endif %} {% if field.isSensitive %}{{ 'Sensitive'|trans }}{% endif %} {% if 'number' == field.type.value and (field.minimumValue is not null or field.maximumValue is not null) %} - [{{ field.minimumValue ?? '…' }}, {{ field.maximumValue ?? '…' }}] + [{{ field.minimumValue ?? '...' }}, {{ field.maximumValue ?? '...' }}] {% endif %} {% if field.options|length > 0 %}
{{ field.options|map(option => option.value|localise_text)|join(', ') }}
diff --git a/templates/career/admin/vacancies/categories/index.html.twig b/templates/career/admin/vacancies/categories/index.html.twig deleted file mode 100644 index d666ded85e..0000000000 --- a/templates/career/admin/vacancies/categories/index.html.twig +++ /dev/null @@ -1,32 +0,0 @@ -{% extends 'layout-admin.html.twig' %} - -{% block page_title %}{{ 'Categories'|trans|page_title }}{{ 'Vacancies'|trans|page_title }}{{ 'Career'|trans|page_title }}{{ parent() }}{% endblock %} - -{% block admin_breadcrumbs %} - - - - -{% endblock %} - -{% block contents %} -
-
- {# TODO #} -
-
-{% endblock %} diff --git a/templates/career/company.html.twig b/templates/career/company.html.twig new file mode 100644 index 0000000000..f396cf96c8 --- /dev/null +++ b/templates/career/company.html.twig @@ -0,0 +1,121 @@ +{% extends 'layout.html.twig' %} + +{% block page_title %}{{ company.getName|page_title }}{{ parent() }}{% endblock %} + +{% block breadcrumbs %} + + +{% endblock %} + +{% block contents %} + {% set website = company.getWebsite|localise_text %} +
+
+
+
+ {{ company.getName|slice(0, 1)|upper }} +
+ {% if + company.getContactName is not null + or company.getContactAddress is not null + or company.getContactPhone is not null + or company.getContactEmail is not null + or website is not empty + %} +
+
{{ 'Contact'|trans }}
+ {% if company.getContactName is not null %} +

{{ company.getContactName }}

+ {% endif %} + {% if company.getContactAddress is not null %} +

{{ company.getContactAddress|nl2br }}

+ {% endif %} + {% if company.getContactPhone is not null %} +

+ {{ company.getContactPhone }} +

+ {% endif %} + {% if company.getContactEmail is not null %} +

+ {{ company.getContactEmail }} +

+ {% endif %} + {% if website is not empty %} +

+ + {{ 'Visit website'|trans }} + +

+ {% endif %} +
+ {% endif %} +
+ + {% if company.getNumberOfActiveJobs() > 0 %} +
+
+

{{ 'Open positions'|trans }}

+
+
+ {% for category in vacancy_categories() %} + {% set vacancies = company.getActiveVacancies(category) %} + {% if vacancies is not empty %} + {% set vacancy = vacancies|first %} +
+
{{ category.pluralLabel|trans }}
+

+ + {{ vacancy.getName|localise_text }} + +

+
+ {% endif %} + {% endfor %} +
+
+ {% endif %} + + {% if activities is not empty %} +
+
+

{{ 'Activities'|trans }}

+
+
+ {% for activity in activities %} +
+
+ + {{ activity.getName|localise_text }} + +
+

+ {{ activity.getBeginTime|format_datetime( + pattern: 'EEE d MMM yyyy, HH:mm', + locale: app.request.locale, + ) }} +

+
+ {% endfor %} +
+
+ {% endif %} +
+ +
+

{{ company.getName }}

+ {% set slogan = company.getSlogan|localise_text %} + {% if slogan is not empty %} +

{{ slogan }}

+ {% endif %} +
+ {{ company.getDescription|localise_text|markdown }} +
+
+
+{% endblock %} diff --git a/templates/career/index.html.twig b/templates/career/index.html.twig index c1c867980d..e78e48eaf5 100644 --- a/templates/career/index.html.twig +++ b/templates/career/index.html.twig @@ -1,6 +1,69 @@ {% extends 'layout.html.twig' %} -{% block page_title %}{{ 'Career'|trans|page_title }}{{ parent() }}{% endblock %} +{% block page_title %}{{ 'Companies'|trans|page_title }}{{ parent() }}{% endblock %} {% block contents %} +

{{ 'Companies'|trans }}

+ + {% set featuredPackage = featured_company() %} + {% if featuredPackage is not null %} + {% set featuredCompany = featuredPackage.getCompany %} +
+
+

+ + + {{ featuredCompany.getName }} + + {{ 'in the spotlight'|trans }} +

+
+
+ {{ featuredPackage.getArticle|localise_text|markdown }} +
+
+ {% endif %} + + {% if companies is empty %} +

{{ 'There are currently no companies to display.'|trans }}

+ {% endif %} + +
+ {% for company in companies %} +
+
+ + {{ company.getName|slice(0, 1)|upper }} + +
+
+ + {{ company.getName }} + +
+ {% set slogan = company.getSlogan|localise_text %} + {% if slogan is not empty %} +
{{ slogan }}
+ {% endif %} +
+ +
+
+ {% endfor %} +
{% endblock %} diff --git a/templates/career/vacancies.html.twig b/templates/career/vacancies.html.twig new file mode 100644 index 0000000000..38723bd534 --- /dev/null +++ b/templates/career/vacancies.html.twig @@ -0,0 +1,17 @@ +{% extends 'layout.html.twig' %} + +{% block page_title %}{{ 'Vacancies'|trans|page_title }}{{ parent() }}{% endblock %} + +{% block contents %} +
+

{{ 'Vacancies'|trans }}

+ + +
+ + {{ component('Career:VacancyOverview') }} +{% endblock %} diff --git a/templates/career/vacancy.html.twig b/templates/career/vacancy.html.twig new file mode 100644 index 0000000000..48707be88c --- /dev/null +++ b/templates/career/vacancy.html.twig @@ -0,0 +1,84 @@ +{% extends 'layout.html.twig' %} + +{% set company = vacancy.getCompany %} + +{% block page_title %}{{ vacancy.getName|localise_text|page_title }}{{ parent() }}{% endblock %} + +{% block breadcrumbs %} + + + +{% endblock %} + +{% block contents %} + {% set vacancyWebsite = vacancy.getWebsite|localise_text %} + {% set vacancyAttachment = vacancy.getAttachment|localise_text %} + {% set companySlogan = company.getSlogan|localise_text %} +
+
+
+
+ {{ company.getName|slice(0, 1)|upper }} +
+
+

+ {{ company.getName }} +

+ {% set location = vacancy.getLocation|localise_text %} + {% if location is not empty %} +

{{ location }}

+ {% endif %} +
+ + {{ vacancy.getCategory.label|trans }} + + {% for label in vacancy.getLabels %} + {{ label.getName|localise_text }} + {% endfor %} +
+ {% if vacancyWebsite is not empty %} + + {{ 'Visit website'|trans }} + + {% endif %} + {% if vacancyAttachment is not empty %} + + {{ 'View attachment'|trans }} + + {% endif %} +
+
+ + {% if companySlogan is not empty %} +
+
+

{{ 'About %company%'|trans({'%company%': company.getName}) }}

+
+
+

{{ companySlogan }}

+ + {{ 'View company page'|trans }} + +
+
+ {% endif %} +
+ +
+

{{ vacancy.getName|localise_text }}

+

+ {{ 'at %company%'|trans({'%company%': company.getName}) }} +

+
+ {{ vacancy.getDescription|localise_text|markdown }} +
+
+
+{% endblock %} diff --git a/templates/components/Activity/ActivityOverview.html.twig b/templates/components/Activity/ActivityOverview.html.twig index 9e571d8ea9..8dbd48bb7f 100644 --- a/templates/components/Activity/ActivityOverview.html.twig +++ b/templates/components/Activity/ActivityOverview.html.twig @@ -54,7 +54,7 @@
diff --git a/templates/components/Career/VacancyOverview.html.twig b/templates/components/Career/VacancyOverview.html.twig new file mode 100644 index 0000000000..ce8bb8a62e --- /dev/null +++ b/templates/components/Career/VacancyOverview.html.twig @@ -0,0 +1,70 @@ +
+ {# data-live-ignore: keeps the injected label chips intact across re-renders. #} +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ {% if this.labels is not empty %} +
+ +
+
+ {% for label in this.labels %} +
+ + +
+ {% endfor %} +
+
+
+ {% endif %} +
+
+
+
+ + {% if this.vacancies is empty %} +

{{ 'No vacancies match the current filters.'|trans }}

+ {% else %} +
+ {% for vacancy in this.vacancies %} + {% include 'partials/career/vacancy-card.html.twig' with {'vacancy': vacancy} only %} + {% endfor %} +
+ {% endif %} +
diff --git a/templates/partials/activity/admin/form.html.twig b/templates/partials/activity/admin/form.html.twig index 1feca6bcf2..6abd418735 100644 --- a/templates/partials/activity/admin/form.html.twig +++ b/templates/partials/activity/admin/form.html.twig @@ -73,7 +73,7 @@
{{ form_label(form.currentRevision.labels) }}
{{ form_widget(form.currentRevision.labels) }} diff --git a/templates/partials/admin-sidebar.html.twig b/templates/partials/admin-sidebar.html.twig index 5a6a7cb3f2..368bed4cd2 100644 --- a/templates/partials/admin-sidebar.html.twig +++ b/templates/partials/admin-sidebar.html.twig @@ -63,11 +63,6 @@ {{ 'Approvals'|trans }} -
  • - - {{ 'Categories'|trans }} - -
  • {{ 'Labels'|trans }} diff --git a/templates/partials/application/main-nav.html.twig b/templates/partials/application/main-nav.html.twig index 83e739b3bc..343d446af2 100644 --- a/templates/partials/application/main-nav.html.twig +++ b/templates/partials/application/main-nav.html.twig @@ -188,22 +188,37 @@