From 2626d27288131c11945549fcef9b450962ae4b4d Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 16 Feb 2026 13:58:15 +0100 Subject: [PATCH] set up project CRUD --- assets/controllers/organization_controller.js | 2 +- assets/controllers/project_controller.js | 235 ++++++++++++++++++ assets/js/global.js | 19 ++ assets/styles/app.css | 7 + migrations/Version20260211145606.php | 37 +++ migrations/Version20260216092531.php | 32 +++ src/Controller/ApplicationController.php | 16 ++ src/Controller/ProjectController.php | 162 ++++++++++++ src/Entity/Organizations.php | 37 +++ src/Entity/Project.php | 149 +++++++++++ src/Repository/ProjectRepository.php | 61 +++++ src/Service/ProjectService.php | 43 ++++ templates/organization/show.html.twig | 65 ++++- templates/project/index.html.twig | 5 + 14 files changed, 858 insertions(+), 12 deletions(-) create mode 100644 assets/controllers/project_controller.js create mode 100644 migrations/Version20260211145606.php create mode 100644 migrations/Version20260216092531.php create mode 100644 src/Controller/ProjectController.php create mode 100644 src/Entity/Project.php create mode 100644 src/Repository/ProjectRepository.php create mode 100644 src/Service/ProjectService.php create mode 100644 templates/project/index.html.twig diff --git a/assets/controllers/organization_controller.js b/assets/controllers/organization_controller.js index 225c662..a1fc967 100644 --- a/assets/controllers/organization_controller.js +++ b/assets/controllers/organization_controller.js @@ -18,7 +18,7 @@ export default class extends Controller { this.loadActivities(); setInterval(() => { this.loadActivities(); - }, 60000); // Refresh every 60 seconds + }, 300000); // Refresh every 5 minutes } if (this.tableValue && this.sadminValue) { this.table(); diff --git a/assets/controllers/project_controller.js b/assets/controllers/project_controller.js new file mode 100644 index 0000000..bcf2e12 --- /dev/null +++ b/assets/controllers/project_controller.js @@ -0,0 +1,235 @@ +import {Controller} from '@hotwired/stimulus'; +import { Modal } from "bootstrap"; +import {TabulatorFull as Tabulator} from 'tabulator-tables'; +import {eyeIconLink, pencilIcon, TABULATOR_FR_LANG, trashIcon} from "../js/global.js"; + + +export default class extends Controller { + static values = { + listProject : Boolean, + orgId: Number, + admin: Boolean + } + static targets = ["modal", "appList", "nameInput", "formTitle"]; + connect(){ + if(this.listProjectValue){ + this.table(); + } + this.modal = new Modal(this.modalTarget); + } + + table(){ + const columns = [ + {title: "ID ", field: "id", visible: false}, + {title: "Nom du projet ", field: "name", headerFilter: "input", widthGrow: 2, vertAlign: "middle"}, + { + title: "Applications", + field: "applications", + headerSort: false, + hozAlign: "left", + formatter: (cell) => { + const apps = cell.getValue(); + if (!apps || apps.length === 0) { + return "Aucune"; + } + + // Wrap everything in a flex container to keep them on one line + const content = apps.map(app => ` +
+ ${app.name} +
+ `).join(''); + + return `
${content}
`; + } + } + ]; + // 2. Add the conditional column if admin value is true + if (this.adminValue) { + columns.push({ + title: "Base de données", + field: "bddName", + hozAlign: "left", + }, + { + title: "Actions", + field: "id", + width: 120, + hozAlign: "center", + headerSort: false, + formatter: (cell) => { + const id = cell.getValue(); + // Return a button that Stimulus can listen to + return `
+ + +
`; + } + }); + } + + const tabulator = new Tabulator("#tabulator-projectListOrganization", { + langs: TABULATOR_FR_LANG, + locale: "fr", + ajaxURL: `/project/organization/data`, + ajaxConfig: "GET", + pagination: true, + paginationMode: "remote", + paginationSize: 15, + + ajaxParams: {orgId: this.orgIdValue}, + ajaxResponse: (url, params, response) => response, + paginationDataSent: {page: "page", size: "size"}, + paginationDataReceived: {last_page: "last_page"}, + + ajaxSorting: true, + ajaxFiltering: true, + filterMode: "remote", + + ajaxURLGenerator: function(url, config, params) { + let queryParams = new URLSearchParams(); + queryParams.append('orgId', params.orgId); + queryParams.append('page', params.page || 1); + queryParams.append('size', params.size || 15); + + // Add filters + if (params.filter) { + params.filter.forEach(filter => { + queryParams.append(`filter[${filter.field}]`, filter.value); + }); + } + + return `${url}?${queryParams.toString()}`; + }, + rowHeight: 60, + layout: "fitColumns", // activate French + + columns + }) + } + + + async loadApplications() { + try { + const response = await fetch('/application/data/all'); + const apps = await response.json(); + + this.appListTarget.innerHTML = apps.map(app => ` +
+
+ + +
+
+ `).join(''); + } catch (error) { + this.appListTarget.innerHTML = '
Erreur de chargement.
'; + } + } + + async submitForm(event) { + event.preventDefault(); + const formData = new FormData(event.target); + + const payload = { + organizationId: this.orgIdValue, + applications: formData.getAll('applications[]') + }; + + // Only include name if it wasn't disabled (new projects) + if (!this.nameInputTarget.disabled) { + payload.name = formData.get('name'); + } + + const url = this.currentProjectId + ? `/project/edit/${this.currentProjectId}/ajax` + : `/project/new/ajax`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (response.ok) { + this.modal.hide(); + // Use Tabulator's setData() instead of reload() for better UX if possible + location.reload(); + } else { + if (response.status === 409) { + alert("Un projet avec ce nom existe déjà. Veuillez choisir un nom différent."); + } + } + } + + async openEditModal(event) { + const projectId = event.currentTarget.dataset.id; + this.currentProjectId = projectId; + + this.modal.show(); + this.nameInputTarget.disabled = true; + this.formTitleTarget.textContent = "Modifier le projet"; + + try { + // 1. Ensure checkboxes are loaded first + await this.loadApplications(); + + // 2. Fetch the project data + const response = await fetch(`/project/data/${projectId}`); + const project = await response.json(); + + // 3. Set the name + this.nameInputTarget.value = project.name; + + // 4. Check the boxes + // We look for all checkboxes inside our appList target + const checkboxes = this.appListTarget.querySelectorAll('input[type="checkbox"]'); + + checkboxes.forEach(cb => { + cb.checked = project.applications.includes(cb.value); + }); + + } catch (error) { + console.error("Error loading project data", error); + alert("Erreur lors de la récupération des données du projet."); + } + } +// Update your openCreateModal to reset the state + openCreateModal() { + this.currentProjectId = null; + this.modal.show(); + this.nameInputTarget.disabled = false; + this.nameInputTarget.value = ""; + this.formTitleTarget.textContent = "Nouveau Projet"; + this.loadApplications(); + } + + async deleteProject(event) { + const projectId = event.currentTarget.dataset.id; + if (!confirm("Êtes-vous sûr de vouloir supprimer ce projet ?")) { + return; + } + + try { + const response = await fetch(`/project/delete/${projectId}/ajax`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (response.ok) { + location.reload(); + } + }catch (error) { + console.error("Error deleting project", error); + alert("Erreur lors de la suppression du projet."); + } + } +} \ No newline at end of file diff --git a/assets/js/global.js b/assets/js/global.js index 784bef5..80d5c79 100644 --- a/assets/js/global.js +++ b/assets/js/global.js @@ -32,6 +32,25 @@ export function eyeIconLink(url) { `; } +export function pencilIcon() { + return ` + + + + + ` +} + +export function trashIcon(url) { + return ` + + + + + ` +} + + export function deactivateUserIcon() { return ` diff --git a/assets/styles/app.css b/assets/styles/app.css index c819b28..c04f469 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -150,6 +150,13 @@ body { color: var(--primary-blue-dark); } +.color-delete{ + color: var(--delete) !important; +} +.color-delete-dark{ + color: var(--delete-dark); +} + .btn-secondary{ background: var(--secondary); color : #FFFFFF; diff --git a/migrations/Version20260211145606.php b/migrations/Version20260211145606.php new file mode 100644 index 0000000..e916e0f --- /dev/null +++ b/migrations/Version20260211145606.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE project (id SERIAL NOT NULL, organization_id INT NOT NULL, name VARCHAR(255) NOT NULL, applications JSON DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, modified_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, is_active BOOLEAN NOT NULL, is_deleted BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_2FB3D0EE32C8A3DE ON project (organization_id)'); + $this->addSql('COMMENT ON COLUMN project.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN project.modified_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE project ADD CONSTRAINT FK_2FB3D0EE32C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE project DROP CONSTRAINT FK_2FB3D0EE32C8A3DE'); + $this->addSql('DROP TABLE project'); + } +} diff --git a/migrations/Version20260216092531.php b/migrations/Version20260216092531.php new file mode 100644 index 0000000..1082423 --- /dev/null +++ b/migrations/Version20260216092531.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE project ADD bdd_name VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE project DROP bdd_name'); + } +} diff --git a/src/Controller/ApplicationController.php b/src/Controller/ApplicationController.php index ff7e775..f273c18 100644 --- a/src/Controller/ApplicationController.php +++ b/src/Controller/ApplicationController.php @@ -127,4 +127,20 @@ class ApplicationController extends AbstractController return new JsonResponse($data, Response::HTTP_OK); } + + #[Route(path: '/data/all', name: 'data_all', methods: ['GET'])] + public function getAllApplications(): JsonResponse + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $applications = $this->entityManager->getRepository(Apps::class)->findAll(); + $data = array_map(function($app) { + return [ + 'id' => $app->getId(), + 'name' => $app->getName(), + 'subDomain' => $app->getSubDomain(), + 'logoMiniUrl' => $this->assetsManager->getUrl($app->getLogoMiniUrl()), + ]; + }, $applications); + return new JsonResponse($data, Response::HTTP_OK); + } } diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php new file mode 100644 index 0000000..f6511b0 --- /dev/null +++ b/src/Controller/ProjectController.php @@ -0,0 +1,162 @@ +render('project/index.html.twig', [ + 'controller_name' => 'ProjectController', + ]); + } + + #[Route('/new/ajax', name: '_new', methods: ['POST'])] + public function new(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); + $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + if (!$data) { + return new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST); + } $org = $this->organizationsRepository->findOneBy(['id' => $data['organizationId']]); + if(!$org) { + return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND); + } + $sanitizedDbName = $this->projectService->getProjectDbName($data['name'], $org->getProjectPrefix()); + if($this->projectRepository->findOneBy(['bddName' => $sanitizedDbName])) { + return new JsonResponse(['error' => 'A project with the same name already exists'], Response::HTTP_CONFLICT); + } + if(!$this->projectService->isApplicationArrayValid($data['applications'])) { + return new JsonResponse(['error' => 'Invalid applications array'], Response::HTTP_BAD_REQUEST); + } + $project = new Project(); + $project->setName($data['name']); + $project->setBddName($sanitizedDbName); + $project->setOrganization($org); + $project->setApplications($data['applications']); + $this->entityManager->persist($project); + $this->entityManager->flush(); + return new JsonResponse(['message' => 'Project created successfully'], Response::HTTP_CREATED); + } + + #[Route(path:'/edit/{id}/ajax', name: '_edit', methods: ['POST'])] + public function edit(Request $request, int $id): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); + $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + if (!$data) { + return new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST); + } $org = $this->organizationsRepository->findOneBy(['id' => $data['organizationId']]); + if(!$org) { + return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND); + } + $project = $this->projectRepository->findOneBy(['id' => $id]); + if(!$project) { + return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND); + } + $project->setApplications($data['applications']); + $project->setModifiedAt(new \DateTimeImmutable()); + $this->entityManager->persist($project); + $this->entityManager->flush(); + return new JsonResponse(['message' => 'Project updated successfully'], Response::HTTP_OK); + } + + #[Route('/organization/data', name: '_organization_data', methods: ['GET'])] + public function organizationData(Request $request, Packages $assetPackage): JsonResponse { + $this->denyAccessUnlessGranted('ROLE_USER'); + + $page = $request->query->getInt('page', 1); + $size = $request->query->getInt('size', 15); + $filters = $request->query->all('filter'); + $orgId = $request->query->get('orgId'); + + $org = $this->organizationsRepository->findOneBy(['id' => $orgId, 'isDeleted' => false]); + if(!$org) { + return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND); + } + + $paginator = $this->projectRepository->findProjectByOrganization($orgId, $page, $size, $filters); + $total = count($paginator); + + $data = array_map(function (Project $project) use ($assetPackage) { + // Map ONLY the applications linked to THIS specific project + $projectApps = array_map(function($appId) use ($assetPackage) { + // Note: If $project->getApplications() returns IDs, we need to find the entities. + // If your Project entity has a ManyToMany relationship, use $project->getApps() instead. + $appEntity = $this->appsRepository->find($appId); + return $appEntity ? [ + 'id' => $appEntity->getId(), + 'name' => $appEntity->getName(), + 'logoMiniUrl' => $assetPackage->getUrl($appEntity->getLogoMiniUrl()), + ] : null; + }, $project->getApplications() ?? []); + + return [ + 'id' => $project->getId(), + 'name' => ucfirst($project->getName()), + 'applications' => array_filter($projectApps), // Remove nulls + 'bddName' => $project->getBddName(), + 'isActive' => $project->isActive(), + ]; + }, iterator_to_array($paginator)); + + return $this->json([ + 'data' => $data, + 'total' => $total, + 'last_page' => (int)ceil($total / $size), + ]); + } + + #[Route(path: '/data/{id}', name: '_project_data', methods: ['GET'])] + public function projectData(Request $request, int $id): JsonResponse{ + $this->denyAccessUnlessGranted('ROLE_USER'); + $project = $this->projectRepository->findOneBy(['id' => $id]); + if(!$project) { + return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND); + } + return new JsonResponse([ + 'id' => $project->getId(), + 'name' => ucfirst($project->getName()), + 'applications' => $project->getApplications(), + ]); + } + + #[Route(path: '/delete/{id}/ajax', name: '_delete', methods: ['POST'])] + public function delete(int $id): JsonResponse { + $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); + $project = $this->projectRepository->findOneBy(['id' => $id]); + if(!$project) { + return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND); + } + $project->setIsDeleted(true); + $this->entityManager->persist($project); + $this->entityManager->flush(); + return new JsonResponse(['message' => 'Project deleted successfully'], Response::HTTP_OK); + } +} diff --git a/src/Entity/Organizations.php b/src/Entity/Organizations.php index 6cbce6a..470316d 100644 --- a/src/Entity/Organizations.php +++ b/src/Entity/Organizations.php @@ -64,12 +64,19 @@ class Organizations #[ORM\Column(length: 4, nullable: true)] private ?string $projectPrefix = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Project::class, mappedBy: 'organization')] + private Collection $projects; + public function __construct() { $this->apps = new ArrayCollection(); $this->actions = new ArrayCollection(); $this->createdAt = new \DateTimeImmutable(); $this->userOrganizatonApps = new ArrayCollection(); + $this->projects = new ArrayCollection(); } public function getId(): ?int @@ -271,4 +278,34 @@ class Organizations return $this; } + + /** + * @return Collection + */ + public function getProjects(): Collection + { + return $this->projects; + } + + public function addProject(Project $project): static + { + if (!$this->projects->contains($project)) { + $this->projects->add($project); + $project->setOrganization($this); + } + + return $this; + } + + public function removeProject(Project $project): static + { + if ($this->projects->removeElement($project)) { + // set the owning side to null (unless already changed) + if ($project->getOrganization() === $this) { + $project->setOrganization(null); + } + } + + return $this; + } } diff --git a/src/Entity/Project.php b/src/Entity/Project.php new file mode 100644 index 0000000..1512564 --- /dev/null +++ b/src/Entity/Project.php @@ -0,0 +1,149 @@ +createdAt = new \DateTimeImmutable(); + $this->modifiedAt = new \DateTimeImmutable(); + $this->isActive = true; + $this->isDeleted = false; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getOrganization(): ?Organizations + { + return $this->organization; + } + + public function setOrganization(?Organizations $organization): static + { + $this->organization = $organization; + + return $this; + } + + public function getApplications(): ?array + { + return $this->applications; + } + + public function setApplications(?array $applications): static + { + $this->applications = $applications; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getModifiedAt(): ?\DateTimeImmutable + { + return $this->modifiedAt; + } + + public function setModifiedAt(\DateTimeImmutable $modifiedAt): static + { + $this->modifiedAt = $modifiedAt; + + return $this; + } + + public function isActive(): ?bool + { + return $this->isActive; + } + + public function setIsActive(bool $isActive): static + { + $this->isActive = $isActive; + + return $this; + } + + public function isDeleted(): ?bool + { + return $this->isDeleted; + } + + public function setIsDeleted(bool $isDeleted): static + { + $this->isDeleted = $isDeleted; + + return $this; + } + + public function getBddName(): ?string + { + return $this->bddName; + } + + public function setBddName(string $bddName): static + { + $this->bddName = $bddName; + + return $this; + } +} diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php new file mode 100644 index 0000000..9d1d68c --- /dev/null +++ b/src/Repository/ProjectRepository.php @@ -0,0 +1,61 @@ + + */ +class ProjectRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Project::class); + } + + // /** + // * @return Project[] Returns an array of Project objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('p') + // ->andWhere('p.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('p.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Project + // { + // return $this->createQueryBuilder('p') + // ->andWhere('p.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } + public function findProjectByOrganization(int $organizationId, int $page, int $size, array $filters) + { + $qb = $this->createQueryBuilder('p') + ->where('p.organization = :orgId') + ->andWhere('p.isDeleted = :del') + ->setParameter('orgId', $organizationId) + ->setParameter('del', false); + + if (!empty($filters['name'])) { + $qb->andWhere('p.name LIKE :name') + ->setParameter('name', '%' . strtoLower($filters['name']) . '%'); + } + + return $qb->setFirstResult(($page - 1) * $size) + ->setMaxResults($size) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Service/ProjectService.php b/src/Service/ProjectService.php new file mode 100644 index 0000000..11e8d4f --- /dev/null +++ b/src/Service/ProjectService.php @@ -0,0 +1,43 @@ +slug($projectName, '_')->lower()->toString(); +// \d matches any digit character, equivalent to [0-9]. So, the regular expression '/\d/' will match any digit in the string. + $str = preg_replace('/\d/', '', $slug); + return $projectPrefix . '_' . $str; + } + + public function isApplicationArrayValid(array $applicationArray): bool + { + foreach ($applicationArray as $app) { + $app = (int) $app; + if (empty($app) || $app <= 0 || empty($this->appsRepository->findOneBy(['id' => $app]))) { + return false; + } + } + return true; + } +} diff --git a/templates/organization/show.html.twig b/templates/organization/show.html.twig index a24e1b2..8605ad1 100644 --- a/templates/organization/show.html.twig +++ b/templates/organization/show.html.twig @@ -1,6 +1,7 @@ {% extends 'base.html.twig' %} {% block body %} + {% set isSA = is_granted('ROLE_SUPER_ADMIN')%}
{% for type, messages in app.flashes %} {% for message in messages %} @@ -18,7 +19,7 @@

{{ organization.name|title }} - Dashboard

- {% if is_granted("ROLE_SUPER_ADMIN") %} + {% if isSA %} Gérer l'organisation
{# APPLICATION ROW #} - {# TODO: Weird gap not going away #} -
- {% for application in applications %} -
- {% include 'application/appSmall.html.twig' with { - application: application - } %} + {# TODO:remove app acces and replace wioth project overview#} +
+
+

Mes Projets

+ {% if is_granted("ROLE_SUPER_ADMIN") %} + {# Trigger for the Modal #} + + {% endif %} +
+
+
- {% endfor %} +
+ +
{# Activities col #}
-
@@ -122,7 +164,7 @@

Activité récente

-
@@ -148,6 +190,7 @@ + {% endblock %} diff --git a/templates/project/index.html.twig b/templates/project/index.html.twig new file mode 100644 index 0000000..0749a00 --- /dev/null +++ b/templates/project/index.html.twig @@ -0,0 +1,5 @@ +{% extends 'base.html.twig' %} + +{% block body %} + +{% endblock %}