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 @@