Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* [PR-40](https://github.com/itk-dev/itk-projects/pull/40)
Let a project belong to several areas, picked with a searchable multiselect.
* [PR-43](https://github.com/itk-dev/itk-projects/pull/43)
Overhaul "Økonomi og tid": add amount applied for, ITK budget, co-financing,
funding rate and what the rest consists of, and let each relevant link carry a note.
Expand Down
14 changes: 9 additions & 5 deletions assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,17 @@ function initPartnerSelect() {
);
}

// Departments and project types are fixed, admin-managed pools, so a
// <select multiple> — the project form's anchoring and type fields and the
// project list's department filter — becomes a searchable chip multiselect
// Departments, areas and project types are fixed, admin-managed pools, so a
// <select multiple> — the project form's area, anchoring and type fields and
// the project list's department filter — becomes a searchable chip multiselect
// without `create`. Tom Select reads the options and the selection from the
// select itself and fires input/change on it, which is what autosave, the
// progress bar and the live filter listen for.
function initPoolSelect() {
document
.querySelectorAll("[data-department-select], [data-type-select]")
.querySelectorAll(
"[data-department-select], [data-area-select], [data-type-select]",
)
.forEach((select) => {
if (select.dataset.bound) {
return;
Expand Down Expand Up @@ -221,7 +223,9 @@ function initPoolSelect() {
// so turbo:load rebuilds the widget on restore.
function teardownPoolSelect() {
document
.querySelectorAll("[data-department-select], [data-type-select]")
.querySelectorAll(
"[data-department-select], [data-area-select], [data-type-select]",
)
.forEach((select) => {
const tomSelect = select.tomselect;
if (!tomSelect) {
Expand Down
40 changes: 40 additions & 0 deletions migrations/Version20260929150846.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260929150846 extends AbstractMigration
{
public function getDescription(): string
{
return 'Make a project\'s area a many-to-many relation: add the project_area join table, copy each project\'s area into it and drop project.area_id.';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE project_area (project_id BINARY(16) NOT NULL, area_id BINARY(16) NOT NULL, INDEX IDX_EE05F570166D1F9C (project_id), INDEX IDX_EE05F570BD0F409C (area_id), PRIMARY KEY (project_id, area_id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE project_area ADD CONSTRAINT FK_EE05F570166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project_area ADD CONSTRAINT FK_EE05F570BD0F409C FOREIGN KEY (area_id) REFERENCES area (id) ON DELETE CASCADE');
// Carry the existing single area over before the column goes.
$this->addSql('INSERT INTO project_area (project_id, area_id) SELECT id, area_id FROM project WHERE area_id IS NOT NULL');
$this->addSql('ALTER TABLE project DROP FOREIGN KEY `FK_2FB3D0EEBD0F409C`');
$this->addSql('DROP INDEX IDX_2FB3D0EEBD0F409C ON project');
$this->addSql('ALTER TABLE project DROP area_id');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE project ADD area_id BINARY(16) DEFAULT NULL');
$this->addSql('ALTER TABLE project ADD CONSTRAINT `FK_2FB3D0EEBD0F409C` FOREIGN KEY (area_id) REFERENCES area (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_2FB3D0EEBD0F409C ON project (area_id)');
// A project with several areas keeps an arbitrary one of them.
$this->addSql('UPDATE project p JOIN project_area pa ON pa.project_id = p.id SET p.area_id = pa.area_id');
$this->addSql('ALTER TABLE project_area DROP FOREIGN KEY FK_EE05F570166D1F9C');
$this->addSql('ALTER TABLE project_area DROP FOREIGN KEY FK_EE05F570BD0F409C');
$this->addSql('DROP TABLE project_area');
}
}
2 changes: 1 addition & 1 deletion src/Controller/ProjectController.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public function export(Request $request, ProjectRepository $projects, Translator
$row->getTitle(),
$row->getTopic(),
$translate($row->getStatus()),
$row->getArea()?->getName(),
$names($row->getAreas()),
$names($row->getTypes()),
$names($row->getOrganizationalAnchoring()),
$row->isEndorsement() ? $translator->trans('filter.yes') : $translator->trans('filter.no'),
Expand Down
7 changes: 5 additions & 2 deletions src/DataFixtures/AppFixtures.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ public function load(ObjectManager $manager): void
$budget = mt_rand(1, 40) * 50000;
$project = (new Project())
->setTitle($title)
->setArea($areas[array_rand($areas)])
->setStatus($statuses[array_rand($statuses)])
->setSummary('Projektet arbejder med '.mb_strtolower($title).' gennem en tværgående indsats med fokus på borgernes hverdag og kommunens strategiske mål.')
->setDescription('Projektet er sat i gang, fordi kommunen har brug for at styrke indsatsen omkring '.mb_strtolower($title).".\n\nDet har ophæng i byrådets vedtagne strategier og i afdelingens handleplaner og gennemføres i samarbejde med relevante fagområder og eksterne partnere.")
Expand Down Expand Up @@ -177,10 +176,14 @@ public function load(ObjectManager $manager): void
}

// Roughly a third of the projects span two departments so the dashboard's
// cross-department views have something to show.
// cross-department views have something to show; likewise every fourth
// project sits in two areas.
foreach (\array_slice($this->shuffleCopy($departments), 0, 0 === $index % 3 ? 2 : 1) as $department) {
$project->addOrganizationalAnchoring($department);
}
foreach (\array_slice($this->shuffleCopy($areas), 0, 0 === $index % 4 ? 2 : 1) as $area) {
$project->addArea($area);
}

$start = new \DateTimeImmutable(sprintf('2025-%02d-01', mt_rand(1, 12)));
$project->setTimePeriodStart($start);
Expand Down
57 changes: 43 additions & 14 deletions src/Entity/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ class Project extends AbstractEntity
* Fields that count toward {@see getCompletionPercentage()} and the client-side
* progress bar. Limited to the project's own columns so list rendering stays
* query-free; the booleans and the free-tagging lists are intentionally excluded.
* Types and departments are the two collections counted: both are fixed,
* admin-managed pools, and what a project is and where it is anchored are
* core to describing it. Of the economy fields, co-financing is a boolean
* and the remaining-funding text is explicitly optional, so neither counts.
* Areas, types and departments are the three collections counted: all are
* fixed, admin-managed pools, and what a project is about, what kind of thing
* it is and where it is anchored are core to describing it. Of the economy
* fields, co-financing is a boolean and the remaining-funding text is
* explicitly optional, so neither counts.
*
* @var list<string>
*/
public const array COMPLETION_FIELDS = [
'title', 'area', 'summary', 'description', 'types', 'status',
'title', 'areas', 'summary', 'description', 'types', 'status',
'organizationalAnchoring',
'amountApplied', 'budget', 'budgetItk', 'fundingRate', 'funding',
'timePeriodStart', 'timePeriodEnd',
Expand All @@ -48,9 +49,15 @@ class Project extends AbstractEntity
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $topic = null;

#[ORM\ManyToOne(targetEntity: Area::class)]
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?Area $area = null;
/**
* The thematic areas a project belongs to. Like departments, an admin-managed
* pool a project can sit in several of.
*
* @var Collection<int, Area>
*/
#[ORM\ManyToMany(targetEntity: Area::class)]
#[ORM\JoinTable(name: 'project_area')]
private Collection $areas;

/** A few lines summing up the project's purpose, content, strategy and plans. */
#[ORM\Column(type: Types::TEXT, nullable: true)]
Expand Down Expand Up @@ -163,6 +170,7 @@ class Project extends AbstractEntity
public function __construct()
{
parent::__construct();
$this->areas = new ArrayCollection();
$this->organizationalAnchoring = new ArrayCollection();
$this->types = new ArrayCollection();
$this->contacts = new ArrayCollection();
Expand Down Expand Up @@ -194,14 +202,35 @@ public function setTopic(?string $topic): static
return $this;
}

public function getArea(): ?Area
/** @return Collection<int, Area> */
public function getAreas(): Collection
{
return $this->area;
return $this->areas;
}

public function setArea(?Area $area): static
public function addArea(Area $area): static
{
$this->area = $area;
if (!$this->areas->contains($area)) {
$this->areas->add($area);
}

return $this;
}

public function removeArea(Area $area): static
{
$this->areas->removeElement($area);

return $this;
}

/** @param iterable<Area> $areas */
public function setAreas(iterable $areas): static
{
$this->areas->clear();
foreach ($areas as $area) {
$this->addArea($area);
}

return $this;
}
Expand Down Expand Up @@ -567,14 +596,14 @@ public function setLinks(array $links): static

/**
* Share of {@see COMPLETION_FIELDS} that are filled in, as a 0–100 percentage.
* Reads own columns plus the type and department collections (one lazy
* Reads own columns plus the area, type and department collections (one lazy
* load each), so it is cheap enough to call per row in a listing.
*/
public function getCompletionPercentage(): int
{
$checks = [
null !== $this->title && '' !== $this->title,
null !== $this->area,
!$this->areas->isEmpty(),
null !== $this->summary && '' !== $this->summary,
null !== $this->description && '' !== $this->description,
!$this->types->isEmpty(),
Expand Down
7 changes: 5 additions & 2 deletions src/Form/ProjectType.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'attr' => ['rows' => 4],
'help' => 'project.topic_help',
])
->add('area', EntityType::class, [
->add('areas', EntityType::class, [
'label' => 'project.area',
'class' => Area::class,
'choice_label' => 'name',
'multiple' => true,
'required' => false,
'placeholder' => 'form.choose',
// Areas are admin-managed like departments: a searchable multiselect
// (Tom Select, see app.js) without on-the-fly creation.
'attr' => ['data-area-select' => true, 'placeholder' => 'form.choose'],
'help' => 'project.area_help',
])
->add('summary', TextareaType::class, [
Expand Down
64 changes: 37 additions & 27 deletions src/Repository/ProjectRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function search(ProjectFilter $filter): QueryBuilder
// Department, area and type are related entities searched by
// their stored name ("nik" should find "Teknik og Miljø").
sprintf('i.id IN (SELECT idep.id FROM %s idep JOIN idep.organizationalAnchoring dep WHERE LOWER(dep.name) LIKE :q)', Project::class),
sprintf('i.id IN (SELECT iare.id FROM %s iare JOIN iare.area ar WHERE LOWER(ar.name) LIKE :q)', Project::class),
sprintf('i.id IN (SELECT iare.id FROM %s iare JOIN iare.areas ar WHERE LOWER(ar.name) LIKE :q)', Project::class),
sprintf('i.id IN (SELECT ityp.id FROM %s ityp JOIN ityp.types ty WHERE LOWER(ty.name) LIKE :q)', Project::class),
];

Expand Down Expand Up @@ -90,7 +90,7 @@ public function search(ProjectFilter $filter): QueryBuilder
}

if (null !== $filter->area) {
$qb->andWhere('i.area = :area')->setParameter('area', $filter->area->getId(), 'ulid');
$qb->andWhere(':area MEMBER OF i.areas')->setParameter('area', $filter->area->getId(), 'ulid');
}

if (null !== $filter->type) {
Expand Down Expand Up @@ -156,7 +156,7 @@ public function findForExport(ProjectFilter $filter): array
return [];
}

foreach (['types', 'organizationalAnchoring', 'tags', 'contacts', 'partners'] as $association) {
foreach (['areas', 'types', 'organizationalAnchoring', 'tags', 'contacts', 'partners'] as $association) {
$this->createQueryBuilder('i')
->addSelect('rel')
->leftJoin('i.'.$association, 'rel')
Expand Down Expand Up @@ -272,45 +272,55 @@ public function findRecent(int $limit = 5): array
/**
* Lightweight per-project rows for the dashboard numbers: just the
* columns the aggregates need, so it stays cheap to recompute on every live
* broadcast. `organizationalAnchoring` is the list of department ids (as
* strings); the other keys are plain columns.
* broadcast. `areas` and `organizationalAnchoring` are lists of area and
* department ids (as strings); the other keys are plain columns.
*
* @return list<array<string, mixed>>
*/
public function dashboardRows(): array
{
// Join and select the related ids (rather than IDENTITY()) so Doctrine
// applies the ULID type: IDENTITY() returns the raw binary FK, which would
// not match the canonical ULID strings the rest of build() keys on.
$rows = $this->createQueryBuilder('i')
->select(
'i.id',
'ar.id AS area',
'i.status',
'i.budget',
)
->leftJoin('i.area', 'ar')
->select('i.id', 'i.status', 'i.budget')
->getQuery()
->getArrayResult();

// Departments are many-to-many, so they are fetched separately and folded
// in: joining them above would repeat every project once per department.
$departmentsByProject = [];
// Areas and departments are many-to-many, so each is fetched separately
// and folded in: joining them above would repeat every project once per
// area times department.
$areasByProject = $this->relatedIdsByProject('areas');
$departmentsByProject = $this->relatedIdsByProject('organizationalAnchoring');

foreach ($rows as &$row) {
$id = (string) $row['id'];
$row['areas'] = $areasByProject[$id] ?? [];
$row['organizationalAnchoring'] = $departmentsByProject[$id] ?? [];
unset($row['id']);
}

return $rows;
}

/**
* The ids of a to-many association, as strings, keyed by project id.
*
* @return array<string, list<string>>
*/
private function relatedIdsByProject(string $association): array
{
// Join and select the related id (rather than IDENTITY()) so Doctrine
// applies the ULID type: IDENTITY() returns the raw binary FK, which would
// not match the canonical ULID strings DashboardData keys on.
$pairs = $this->createQueryBuilder('i')
->select('i.id AS project', 'd.id AS department')
->join('i.organizationalAnchoring', 'd')
->select('i.id AS project', 'rel.id AS related')
->join('i.'.$association, 'rel')
->getQuery()
->getArrayResult();

$byProject = [];
foreach ($pairs as $pair) {
$departmentsByProject[(string) $pair['project']][] = (string) $pair['department'];
$byProject[(string) $pair['project']][] = (string) $pair['related'];
}

foreach ($rows as &$row) {
$row['organizationalAnchoring'] = $departmentsByProject[(string) $row['id']] ?? [];
unset($row['id']);
}

return $rows;
return $byProject;
}
}
15 changes: 12 additions & 3 deletions src/Service/DashboardData.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,18 @@ public function build(): array

foreach ($this->projects->dashboardRows() as $row) {
++$total;
$area = $this->enumValue($row['area'] ?? null);
$status = $this->enumValue($row['status'] ?? null);
$si = null !== $status ? ($statusIndex[$status] ?? null) : null;

// A project belongs to any number of areas, keyed by id.
$areas = [];
foreach (($row['areas'] ?? []) as $areaId) {
$area = $this->enumValue($areaId);
if (null !== $area) {
$areas[$area] = true;
}
}

// A project is anchored in any number of departments; keep only the
// ids that resolve to a known department, as department indexes.
$depts = [];
Expand All @@ -73,10 +81,11 @@ public function build(): array

// Every per-department aggregate counts a shared project once under
// each of its departments: the budget shows what each department is
// involved in, so the bars sum to more than the plain total.
// involved in, so the bars sum to more than the plain total. Each of
// the project's areas is marked as worked on in each department.
foreach ($depts as $di) {
$deptsSeen[$di] = true;
if (null !== $area) {
foreach (array_keys($areas) as $area) {
$deptsByArea[$area][$di] = true;
}
if (null !== ($row['budget'] ?? null)) {
Expand Down
2 changes: 1 addition & 1 deletion templates/project/_form.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
{{ form_row(form.title, {attr: title_attr}) }}
{{ form_row(form.topic) }}
<div class="form-grid">
{{ form_row(form.area) }}
{{ form_row(form.areas) }}
{{ form_row(form.types) }}
</div>
{{ form_row(form.summary) }}
Expand Down
2 changes: 1 addition & 1 deletion templates/project/show.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
<div class="card__body">
<dl class="detail-list">
<dt>{{ 'project.area'|trans }}</dt>
<dd>{{ project.area ? project.area.name : '—' }}</dd>
<dd>{{ h.term_list(project.areas) }}</dd>

<dt>{{ 'project.types'|trans }}</dt>
<dd>{{ h.term_list(project.types) }}</dd>
Expand Down
Loading
Loading