This commit is contained in:
2025-07-07 17:30:12 +02:00
parent 94dbc9f712
commit f7de5f8f9c
9 changed files with 639 additions and 562 deletions

View File

@ -2,8 +2,6 @@
namespace App\Controller;
use App\Repository\IssueRepository;
use App\Service\RedmineService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -11,49 +9,15 @@ use Symfony\Component\Routing\Attribute\Route;
class HomeController extends AbstractController
{
private RedmineService $redmineService;
public function __construct(RedmineService $redmineService)
{
$this->redmineService = $redmineService;
}
#[Route('/', name: 'app_home')]
public function home(Request $request): Response
{
$project = $request->getSession()->get('project');
if (!$project) {
return $this->noproject();
}
$redmine = $this->redmineService->getProject($project->getId(), $this->getParameter('redmineApikey'));
$project->setRedmine($redmine);
$this->redmineService->majProjectIssues($project, $this->getParameter('redmineApikey'), false);
$predmine = $project->getRedmine();
foreach ($project->getIssues() as $issue) {
foreach ($predmine['sprints'] as $key => $sprint) {
if ($sprint['id'] === $issue->getRedmine()['sprint']['agile_sprint_id']) {
if (!array_key_exists('story_points', $predmine['sprints'][$key])) {
$predmine['sprints'][$key]['story_points']['total'] = 0;
}
$predmine['sprints'][$key]['story_points']['total'] += $issue->getRedmine()['sprint']['story_points'];
if (!array_key_exists($issue->getRedmine()['status']['id'], $predmine['sprints'][$key]['story_points'])) {
$predmine['sprints'][$key]['story_points'][$issue->getRedmine()['status']['id']] = 0;
}
$predmine['sprints'][$key]['story_points'][$issue->getRedmine()['status']['id']] += $issue->getRedmine()['sprint']['story_points'];
}
}
}
$project->setRedmine($predmine);
$projects = $this->getUser()->getProjects();
return $this->render('home/home.html.twig', [
'usemenu' => true,
'usesidebar' => false,
'project' => $project,
'projects' => $projects,
]);
}
@ -65,23 +29,4 @@ class HomeController extends AbstractController
'usesidebar' => true,
]);
}
#[Route('/noproject', name: 'app_noproject')]
public function noproject(): Response
{
return $this->render('home/noproject.html.twig', [
'usemenu' => true,
'usesidebar' => false,
]);
}
#[Route('/user/issue/{id}', name: 'issue_view')]
public function view(int $id, IssueRepository $issueRepository): Response
{
$issue = $issueRepository->find($id);
return $this->render('issue/view.html.twig', [
'issue' => $issue,
]);
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Controller;
use App\Repository\IssueRepository;
use App\Service\RedmineService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
class IssueController extends AbstractController
{
private RedmineService $redmineService;
public function __construct(RedmineService $redmineService)
{
$this->redmineService = $redmineService;
}
#[Route('/user/issue/{id}', name: 'app_issue_view')]
public function viewIssue(int $id, IssueRepository $issueRepository): Response
{
$issue = $issueRepository->find($id);
if (!$issue) {
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
if (!$issue->getProject()->getUsers()->contains($this->getUser())) {
throw new AccessDeniedException('Vous n\'avez pas accès à cette ressource.');
}
return $this->render('issue/view.html.twig', [
'issue' => $issue,
]);
}
#[Route('/user/issue/order/{id}', name: 'app_issue_order', methods: ['POST'])]
public function orderIssue(int $id, Request $request): JsonResponse
{
$data = $request->request;
$sourceIssues = $data->all('sourceIssues');
$source = explode('|', $data->get('source'));
$sourceStatus = $source[0];
$sourceSprint = $source[1];
$sourceVersion = $source[2];
$target = explode('|', $data->get('target'));
$targetStatus = $target[0];
$targetSprint = $target[1];
$targetVersion = $target[2];
$targetIssues = $data->all('targetIssues');
if (!$sourceIssues || !$source || !$targetIssues || !$target) {
return new JsonResponse(['error' => 'Données incomplètes.'], 400);
}
$payload = [
'fixed_version_id' => $target,
];
// $redmineService->updateIssue($id, $payload);
// ✅ Tu peux stocker lordre si besoin dans Redmine via custom field (optionnel)
// ou en interne selon ta logique métier
return new JsonResponse([
'message' => 'Ordre mis à jour',
'moved' => $id,
'sourceIssues' => $sourceIssues,
'sourceStatus' => $targetStatus,
'sourceSprint' => $sourceSprint,
'sourceVersion' => $sourceVersion,
'targetIssues' => $targetIssues,
'targetStatus' => $targetStatus,
'targetSprint' => $targetSprint,
'targetVersion' => $targetVersion,
]);
}
}

View File

@ -9,8 +9,10 @@ use App\Repository\ProjectRepository;
use App\Service\RedmineService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
class ProjectController extends AbstractController
@ -22,6 +24,48 @@ class ProjectController extends AbstractController
$this->redmineService = $redmineService;
}
#[Route('/user/project/{id}', name: 'app_project_view')]
public function viewProject(int $id, ProjectRepository $projectRepository): Response
{
$project = $projectRepository->find($id);
if (!$project) {
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
if (!$project->getUsers()->contains($this->getUser())) {
throw new AccessDeniedException('Vous n\'avez pas accès à cette ressource.');
}
$redmine = $this->redmineService->getProject($project->getId(), $this->getParameter('redmineApikey'));
$project->setRedmine($redmine);
$this->redmineService->majProjectIssues($project, $this->getParameter('redmineApikey'), false);
$predmine = $project->getRedmine();
foreach ($project->getIssues() as $issue) {
foreach ($predmine['sprints'] as $key => $sprint) {
if ($sprint['id'] === $issue->getRedmine()['sprint']['agile_sprint_id']) {
if (!array_key_exists('story_points', $predmine['sprints'][$key])) {
$predmine['sprints'][$key]['story_points']['total'] = 0;
}
$predmine['sprints'][$key]['story_points']['total'] += $issue->getRedmine()['sprint']['story_points'];
if (!array_key_exists($issue->getRedmine()['status']['id'], $predmine['sprints'][$key]['story_points'])) {
$predmine['sprints'][$key]['story_points'][$issue->getRedmine()['status']['id']] = 0;
}
$predmine['sprints'][$key]['story_points'][$issue->getRedmine()['status']['id']] += $issue->getRedmine()['sprint']['story_points'];
}
}
}
$project->setRedmine($predmine);
return $this->render('project/view.html.twig', [
'usemenu' => true,
'usesidebar' => false,
'project' => $project,
]);
}
#[Route('/admin/project', name: 'app_admin_project')]
public function list(ProjectRepository $projectRepository): Response
{
@ -66,11 +110,11 @@ class ProjectController extends AbstractController
}
#[Route('/admin/project/update/{id}', name: 'app_admin_project_update')]
public function update(int $id, Request $request, EntityManagerInterface $em): Response
public function update(int $id, Request $request, ProjectRepository $projectRepository, EntityManagerInterface $em): Response
{
$project = $em->getRepository(Project::class)->find($id);
$project = $projectRepository->find($id);
if (!$project) {
return $this->redirectToRoute('app_admin_project');
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
$redmine = $this->redmineService->getProject($project->getId(), $this->getParameter('redmineApikey'));
@ -102,11 +146,11 @@ class ProjectController extends AbstractController
}
#[Route('/admin/project/reclone/{id}', name: 'app_admin_project_reclone')]
public function reclone(int $id, EntityManagerInterface $em): Response
public function reclone(int $id, ProjectRepository $projectRepository, EntityManagerInterface $em): Response
{
$project = $em->getRepository(Project::class)->find($id);
$project = $projectRepository->find($id);
if (!$project) {
return $this->redirectToRoute('app_admin_project');
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
$redmine = $this->redmineService->getProject($project->getId(), $this->getParameter('redmineApikey'));
@ -118,12 +162,11 @@ class ProjectController extends AbstractController
}
#[Route('/admin/project/delete/{id}', name: 'app_admin_project_delete')]
public function delete(int $id, EntityManagerInterface $em): Response
public function delete(int $id, ProjectRepository $projectRepository, EntityManagerInterface $em): Response
{
// Récupération de l'enregistrement courant
$project = $em->getRepository(Project::class)->find($id);
$project = $projectRepository->find($id);
if (!$project) {
return $this->redirectToRoute('app_admin_project');
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
$users = $em->getRepository(User::class)->findBy(['project' => $project]);

View File

@ -1,49 +0,0 @@
<?php
namespace App\EventListener;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;
final class SessionListener
{
private Security $security;
private RouterInterface $router;
public function __construct(Security $security, RouterInterface $router)
{
$this->security = $security;
$this->router = $router;
}
#[AsEventListener(event: KernelEvents::REQUEST)]
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$session = $request->getSession();
$user = $this->security->getUser();
if ($user instanceof User) {
// Intialisation de la compagnie en cours
if (!$user->getProject()) {
if ($user->getProjects()) {
$user->setProject($user->getProjects()[0]);
}
}
$session->set('project', $user->getProject());
$session->set('projects', $user->getProjects());
$currentPath = $request->getPathInfo();
$noProjectPath = $this->router->generate('app_noproject');
if (!$user->getProject() && 0 === stripos($currentPath, 'noproject')) {
$event->setResponse(new RedirectResponse($noProjectPath));
}
}
}
}

View File

@ -43,11 +43,7 @@
<nav class="navbar navbar-expand-lg bg-dark" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ path('app_home') }}">
{% if app.session.get('project') %}
<img src="{{asset(app.session.get('project').logo)}}"> Projet = {{app.session.get('project').title}}
{% else %}
<img src="{{asset("medias/logo/logo.png")}}"> {{appName}}
{% endif %}
</a>
<div class="collapse navbar-collapse" id="navbarColor02">
@ -60,15 +56,6 @@
<div class="navbar-nav ms-auto ps-3 d-flex flex-row position-absolute" style="right: 15px; top:15px">
{% if app.user %}
<div class="nav-link">
<select class="select2" name="selectproject" id="selectproject" style="width:200px" data-change="{{path('app_user_selectproject')}}">
<option value="" disabled selected>Selectionnez un projet</option>
{%for project in app.session.get('projects')%}
<option value="{{project.id}}" {{app.session.get('project') and project.id==app.session.get('project').id?"selected":""}}>{{project.title}}</option>
{%endfor%}
</select>
</div>
{% if is_granted('ROLE_ADMIN') %}
<a class="nav-link px-2" href="{{path('app_admin')}}"><i class="fa-solid fa-cog fa-2x"></i></a>
{% endif %}

View File

@ -1,433 +1,17 @@
{% extends 'base.html.twig' %}
{% block localstyle %}
<style>
small {
font-size:70%;
}
verysmall {
font-size:60%;
}
.navbar {
width: 100%;
position: fixed;
z-index: 1000;
}
main {
padding-top:80px;
}
content {
padding:0px;
}
.scrumContainer {
display:none;
}
.issueContainer {
position:fixed;
width:750px;
}
.filtreContainer{
display:flex;
width:300px;
flex-direction:column;
background-color: var(--bs-dark);
padding:5px;
}
.containerStatus{
display:flex;
//width:10000px;
overflow-x:scroll;
}
.statusCard {
width:300px;
padding: 15px 10px 0px 10px;
}
h2 {
font-size:18px;
text-align: center;
}
.card {
margin-bottom:5px;
}
.sprintCardHeader {
display: flex;
}
.versionCard {
background-color: var(--bs-gray-100);
padding: 5px 5px 0px 5px;
}
.versionBody {
min-height:60px;
}
.versionCard h5 {
color: var(--bs-primary-text-emphasis);
font-size:16px;
padding-left:10px;
}
.issueCard {
padding:5px;
}
.issueHeader {
display: flex;
align-items: center;
}
.issueId{
padding: 0px 5px 0px 2px;
cursor: move;
}
.issueAction{
align-self: baseline;
padding: 0px 2px 0px 5px;
text-align:center;
display: flex;
flex-direction: column;
}
.fa-eye {
cursor: pointer;
}
.issueSubject{
zoom: 70%;
flex-grow: 1;
}
</style>
{% endblock %}
{%block body%}
<h2>Projets</h2>
<div class='issueContainer'>
<div class='d-flex' style='justify-content: center'>
{% for project in projects %}
<div class='card'>
<a href='{{ path('app_project_view',{id:project.id})}}' class='d-flex' style='flex-direction: column; text-align: center'>
<img src='{{asset(project.logo)}}' style='width:250px'>
<h5>{{project.title}}</h5>
</a>
</div>
<div class='scrumContainer'>
<div class='filtreContainer'>
<label>Statut</label>
<select id="statusFilter" class="select2 form-select" multiple="true" tabindex="-1" aria-hidden="true">
{% for statut in project.redmine.issue_statuses %}
{% if statut.id not in project.hiddenstatuses %}
<option value="{{statut.id}}">{{statut.name}}</option>
{% endif %}
{% endfor %}
</select>
<label>Sprints</label>
<select id="sprintFilter" class="select2 form-select" multiple="true" tabindex="-1" aria-hidden="true">
<option value="None">Aucun</option>
{% for sprint in project.redmine.sprints|reverse %}
{% if sprint.id not in project.hiddensprints %}
<option value="{{sprint.id}}">{{sprint.name}}</option>
{% endif %}
{% endfor %}
</select>
<label>Versions</label>
<select id="versionFilter" class="select2 form-select" multiple="true" tabindex="-1" aria-hidden="true">
<option value="None">Aucune</option>
{% for version in project.redmine.versions|reverse %}
{% if version.id not in project.hiddenversions %}
<option value="{{version.id}}">{{version.name}}</option>
{% endif %}
{% endfor %}
</select>
<button id="toggleIssueBody" class="btn btn-secondary btn-sm mt-3">Afficher Détail</button>
</div>
<div class='containerStatus'>
{% for status in project.redmine.issue_statuses %}
{% if status.id not in project.hiddenstatuses %}
<div class='statusCard statusCard{{status.id}}'>
<h2>{{ status.name }}</h2>
{% for sprint in project.redmine.sprints|reverse %}
{% if sprint.id not in project.hiddensprints %}
<div class='sprintCard sprintCard{{sprint.id}} card' style='margin-bottom:20px'>
<div class='sprintCardHeader card-header'>
<div style='flex-grow:1'>Sprint = {{ sprint.name }}</div>
{% if sprint.story_points[status.id] is defined %}
<div>{{ sprint.story_points[status.id] }}</div>
{% endif %}
</div>
{% for version in project.redmine.versions|reverse %}
{% if version.id not in project.hiddenversions %}
<div class='versionCard versionCard{{version.id}} card-body'>
<h5>Version = {{ version.name }}</h5>
<div class='versionBody' data-id='{{status.id}}-{{sprint.id}}-{{version.name}}'></div>
</div>
{% endif %}
{% endfor %}
<div class='versionCard versionCardNone card-body'>
<h5>Version = Aucune</h5>
<div class='versionBody' data-id='{{status.id}}-{{sprint.id}}-'></div>
</div>
</div>
{% endif %}
{% endfor %}
<div class='sprintCard sprintCardNone card'>
<div class='card-header'>
Sprint Aucun
</div>
{% for version in project.redmine.versions|reverse %}
{% if version.id not in project.hiddenversions %}
<div class='versionCard versionCard{{version.id}} card-body'>
<h5>Version = {{ version.name }}</h5>
<div class='versionBody' data-id='{{status.id}}--{{version.name}}'></div>
</div>
{% endif %}
{% endfor %}
<div class='versionCard versionCardNone card-body'>
<h5>Version = Aucune</h5>
<div class='versionBody' data-id='{{status.id}}--'></div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% for issue in project.issues %}
<div class="issueCard card" data-status='{{issue.redmine.status.id}}' data-sprint='{{issue.rowsprint}}' data-version='{{issue.rowversion}}'>
<div class='issueHeader'>
<div class='issueId'>#{{issue.id}}</div>
<div class='issueSubject'>{{issue.redmine.subject}}</div>
<div class='issueAction'>
<i class='fas fa-eye' onClick='fetchAndRenderIssue({{issue.id}})'></i>
<verysmall>{{issue.redmine.sprint.story_points}}</verysmall>
</div>
</div>
<div class='issueBody'>
sprint = {{issue.rowsprint}}<br>
version = {{issue.rowversion}}<br>
status = {{issue.redmine.status.name}}
</div>
</div>
{% endfor %}
</div>
{%endblock%}
{% block javascripts %}
<script>
let showIssuebody = false;
function adjustHeight() {
console.log("here");
let ele = $(".issueDescription");
if (ele.length) {
ele.css("height", window.innerHeight - ele.offset().top + "px");
}
}
function showhide() {
// Statut
selected = $('#statusFilter').val();
if (!selected || selected.length === 0) {
$('.statusCard').show();
}
else {
$('.statusCard').hide();
selected.forEach(function (id) {
$('.statusCard' + id).show();
});
}
// Sprint
selected = $('#sprintFilter').val();
if (!selected || selected.length === 0) {
$('.sprintCard').show();
}
else {
$('.sprintCard').hide();
selected.forEach(function (id) {
$('.sprintCard' + id).show();
});
}
// Version
selected = $('#versionFilter').val();
if (!selected || selected.length === 0) {
$('.versionCard').show();
}
else {
$('.versionCard').hide();
selected.forEach(function (id) {
$('.versionCard' + id).show();
});
}
}
function toogleIssueBody() {
if (showIssuebody) {
$('.issueBody').show();
$(this).text('Masquer Détail');
} else {
$('.issueBody').hide();
$(this).text('Afficher Détail');
}
showIssuebody=!showIssuebody;
}
$(document).ready(function () {
// Ranger les issues
$('.issueCard').each(function () {
const id = $(this).data('status')+'-'+$(this).data('sprint')+'-'+$(this).data('version');
const $column = $(`[data-id='${id}']`);
if ($column.length) {
$column.append($(this));
}
else {
console.log ('no ='+id);
$(this).hide();
}
});
// Afficher le scrum après rangement
$('.scrumContainer').css('display', 'flex');
// Filtre
$('#statusFilter').on('change',showhide);
$('#sprintFilter').on('change',showhide);
$('#versionFilter').on('change',showhide);
showhide();
// issueBody
$('#toggleIssueBody').on('click',toogleIssueBody);
toogleIssueBody();
// Ajuste height
adjustHeight();
window.addEventListener("resize", adjustHeight);
});
function fetchAndRenderIssue(issueId) {
url='{{path('issue_view',{id:'xxx'})}}';
url=url.replace('xxx',issueId);
console.log(url);
$.ajax({
url: url,
method: 'GET',
dataType: 'html',
success: function (html) {
$('.issueContainer').html(html);
$('.issueContainer').show();
$('.scrumContainer').css('padding-left','750px');
$('.issueDescription').animate({scrollTop: 0}, 0);
adjustHeight();
},
error: function (xhr, status, error) {
console.error('Erreur lors du chargement de lissue :', error);
$('.issueContainer').html('<div class="alert alert-danger">Erreur lors du chargement de lissue.</div>');
}
});
}
$(function () {
let $sourceContainer = null;
let $movedItem = null;
let originalIndex = null;
$('.versionBody').sortable({
connectWith: '.versionBody',
items: '.issueCard',
handle: '.issueId',
placeholder: 'issue-placeholder',
forcePlaceholderSize: true,
start: function (event, ui) {
$sourceContainer = ui.item.parent();
$movedItem = ui.item;
originalIndex = ui.item.index();
},
update: function (event, ui) {
// ❗ Se déclenche même pour tri dans la même colonne
if (!event.originalEvent) return; // ← ignorer les appels indirects
const $targetContainer = $(this);
const sourceId = $sourceContainer.data('id');
const targetId = $targetContainer.data('id');
// Ne pas dupliquer l'appel si cest un vrai "receive"
if (ui.sender) return;
sendUpdate(sourceId, targetId, $sourceContainer, $targetContainer, $movedItem, originalIndex);
},
receive: function (event, ui) {
const $targetContainer = $(this);
const sourceId = $sourceContainer.data('id');
const targetId = $targetContainer.data('id');
sendUpdate(sourceId, targetId, $sourceContainer, $targetContainer, $movedItem, originalIndex);
}
});
function sendUpdate(sourceId, targetId, $sourceContainer, $targetContainer, $movedItem, originalIndex) {
const sourceIssues = $sourceContainer.find('.issueCard').map(function () {
return $(this).data('id');
}).get();
const targetIssues = $targetContainer.find('.issueCard').map(function () {
return $(this).data('id');
}).get();
$.ajax({
url: '/your/route/here', // 🔁 à adapter
method: 'POST',
data: {
sourceVersion: sourceId,
targetVersion: targetId,
sourceIssues: sourceIssues,
targetIssues: targetIssues
},
success: function (response) {
console.log('Déplacement réussi', response);
},
error: function (xhr) {
console.error('Erreur AJAX', xhr);
// Annuler le déplacement
if ($movedItem && $sourceContainer) {
const items = $sourceContainer.children();
if (originalIndex >= items.length) {
$sourceContainer.append($movedItem);
} else {
$movedItem.insertBefore(items.eq(originalIndex));
}
}
}
});
}
});
</script>
{% endblock %}

View File

@ -1,4 +0,0 @@
{% extends 'base.html.twig' %}
{%block body%}
<h2>Veuillez selectionner un projet</h2>
{%endblock%}

View File

@ -43,7 +43,7 @@
<div class="mb-3">
<hr>
<strong>Description :</strong>
<p>{{ issue.redmine.description|markdown_to_html|textile_to_html }}</p>
<p>{{ issue.redmine.description|textile_to_html|markdown_to_html }}</p>
</div>
{% endif %}

View File

@ -0,0 +1,486 @@
{% extends 'base.html.twig' %}
{% block localstyle %}
<style>
small {
font-size:70%;
}
verysmall {
font-size:60%;
}
.navbar {
width: 100%;
position: fixed;
z-index: 1000;
}
main {
padding-top:80px;
}
content {
padding:0px;
}
.scrumContainer {
display:none;
}
.issueContainer {
position:fixed;
width:750px;
}
.filtreContainer{
display:flex;
width:300px;
flex-direction:column;
background-color: var(--bs-dark);
padding:5px;
}
.containerStatus{
display:flex;
//width:10000px;
overflow-x:scroll;
}
.statusCard {
width:300px;
padding: 15px 10px 0px 10px;
}
h2 {
font-size:18px;
text-align: center;
}
.card {
margin-bottom:5px;
}
.sprintCardHeader {
display: flex;
}
.versionCard {
background-color: var(--bs-gray-100);
padding: 5px 5px 0px 5px;
}
.versionBody {
min-height:60px;
}
.versionCard h5 {
color: var(--bs-primary-text-emphasis);
font-size:16px;
padding-left:10px;
}
.issueCard {
padding:5px;
}
.issueHeader {
display: flex;
align-items: center;
}
.issueId{
padding: 0px 5px 0px 2px;
cursor: move;
}
.issueAction{
align-self: baseline;
padding: 0px 2px 0px 5px;
text-align:center;
display: flex;
flex-direction: column;
}
.fa-eye {
cursor: pointer;
}
.issueSubject{
zoom: 70%;
flex-grow: 1;
}
</style>
{% endblock %}
{% block body %}
<div class='issueContainer'>
</div>
<div class='scrumContainer'>
<div class='filtreContainer'>
<label>Statut</label>
<select id="statusFilter" class="select2 form-select" multiple="true" tabindex="-1" aria-hidden="true">
{% for statut in project.redmine.issue_statuses %}
{% if statut.id not in project.hiddenstatuses %}
<option value="{{statut.id}}">{{statut.name}}</option>
{% endif %}
{% endfor %}
</select>
<label>Sprints</label>
<select id="sprintFilter" class="select2 form-select" multiple="true" tabindex="-1" aria-hidden="true">
<option value="None">Aucun</option>
{% for sprint in project.redmine.sprints|reverse %}
{% if sprint.id not in project.hiddensprints %}
<option value="{{sprint.id}}">{{sprint.name}}</option>
{% endif %}
{% endfor %}
</select>
<label>Versions</label>
<select id="versionFilter" class="select2 form-select" multiple="true" tabindex="-1" aria-hidden="true">
<option value="None">Aucune</option>
{% for version in project.redmine.versions|reverse %}
{% if version.id not in project.hiddenversions %}
<option value="{{version.id}}">{{version.name}}</option>
{% endif %}
{% endfor %}
</select>
<label>Trackers</label>
<select id="trackerFilter" class="select2 form-select" multiple="true" tabindex="-1" aria-hidden="true">
{% for tracker in project.redmine.trackers %}
<option value="tracker{{tracker.id}}">{{tracker.name}}</option>
{% endfor %}
</select>
<label>Catégories</label>
<select id="categoryFilter" class="select2 form-select" multiple="true" tabindex="-1" aria-hidden="true">
{% for category in project.redmine.issue_categories %}
<option value="category{{category.id}}">{{category.name}}</option>
{% endfor %}
</select>
<button id="toggleIssueBody" class="btn btn-secondary btn-sm mt-3">Afficher Détail</button>
</div>
<div class='containerStatus'>
{% for status in project.redmine.issue_statuses %}
{% if status.id not in project.hiddenstatuses %}
<div class='statusCard statusCard{{status.id}}'>
<h2>{{ status.name }}</h2>
{% for sprint in project.redmine.sprints|reverse %}
{% if sprint.id not in project.hiddensprints %}
<div class='sprintCard sprintCard{{sprint.id}} card' style='margin-bottom:20px'>
<div class='sprintCardHeader card-header'>
<div style='flex-grow:1'>Sprint = {{ sprint.name }}</div>
{% if sprint.story_points[status.id] is defined %}
<div>{{ sprint.story_points[status.id] }}</div>
{% endif %}
</div>
{% for version in project.redmine.versions|reverse %}
{% if version.id not in project.hiddenversions %}
<div class='versionCard versionCard{{version.id}} card-body'>
<h5>Version = {{ version.name }}</h5>
<div class='versionBody' data-id='{{status.id}}|{{sprint.id}}|{{version.id}}'></div>
</div>
{% endif %}
{% endfor %}
<div class='versionCard versionCardNone card-body'>
<h5>Version = Aucune</h5>
<div class='versionBody' data-id='{{status.id}}|{{sprint.id}}|'></div>
</div>
</div>
{% endif %}
{% endfor %}
<div class='sprintCard sprintCardNone card'>
<div class='card-header'>
Sprint Aucun
</div>
{% for version in project.redmine.versions|reverse %}
{% if version.id not in project.hiddenversions %}
<div class='versionCard versionCard{{version.id}} card-body'>
<h5>Version = {{ version.name }}</h5>
<div class='versionBody' data-id='{{status.id}}||{{version.id}}'></div>
</div>
{% endif %}
{% endfor %}
<div class='versionCard versionCardNone card-body'>
<h5>Version = Aucune</h5>
<div class='versionBody' data-id='{{status.id}}||'></div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% for issue in project.issues %}
<div class="issueCard card tracker{{issue.redmine.tracker.id}} category{{(issue.redmine.category is defined?issue.redmine.category.id:'0') }}" data-status='{{issue.redmine.status.id}}' data-sprint='{{issue.rowsprint}}' data-version='{{(issue.redmine.fixed_version is defined?issue.redmine.fixed_version.id:'')}}' data-id='{{issue.id}}'>
<div class='issueHeader'>
<div class='issueId'>#{{issue.id}}</div>
<div class='issueSubject'>{{issue.redmine.subject}}</div>
<div class='issueAction'>
<i class='fas fa-eye' onClick='fetchAndRenderIssue({{issue.id}})'></i>
<verysmall>{{issue.redmine.sprint.story_points}}</verysmall>
</div>
</div>
<div class='issueBody'>
sprint = {{issue.rowsprint}}<br>
version = {{issue.rowversion}}<br>
status = {{issue.redmine.status.name}}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block javascripts %}
<script>
let showIssuebody = false;
function adjustHeight() {
let ele = $(".issueDescription");
if (ele.length) {
ele.css("height", window.innerHeight - ele.offset().top + "px");
}
}
function showhide() {
// Statut
selected = $('#statusFilter').val();
if (!selected || selected.length === 0) {
$('.statusCard').show();
}
else {
$('.statusCard').hide();
selected.forEach(function (id) {
$('.statusCard' + id).show();
});
}
// Sprint
selected = $('#sprintFilter').val();
if (!selected || selected.length === 0) {
$('.sprintCard').show();
}
else {
$('.sprintCard').hide();
selected.forEach(function (id) {
$('.sprintCard' + id).show();
});
}
// Version
selected = $('#versionFilter').val();
if (!selected || selected.length === 0) {
$('.versionCard').show();
}
else {
$('.versionCard').hide();
selected.forEach(function (id) {
$('.versionCard' + id).show();
});
}
// Filtre issue
$('.issueCard').hide();
const selectedTrackers = $('#trackerFilter').val() || [];
const selectedCategories = $('#categoryFilter').val() || [];
//const selectedCategories = []; // ou ['category1'] etc.
$('.issueCard').each(function () {
const classes = $(this).attr('class').split(/\s+/);
const hasValidTracker =
selectedTrackers.length === 0 ||
selectedTrackers.some(tracker => classes.includes(tracker));
const hasValidCategory =
selectedCategories.length === 0 ||
selectedCategories.some(category => classes.includes(category));
if(hasValidTracker&&hasValidCategory) $(this).show();
});
// Tracker
selected = $('#versionFilter').val();
if (!selected || selected.length === 0) {
$('.versionCard').show();
}
else {
$('.versionCard').hide();
selected.forEach(function (id) {
$('.versionCard' + id).show();
});
}
}
function toogleIssueBody() {
if (showIssuebody) {
$('.issueBody').show();
$(this).text('Masquer Détail');
} else {
$('.issueBody').hide();
$(this).text('Afficher Détail');
}
showIssuebody=!showIssuebody;
}
$(document).ready(function () {
// Ranger les issues
$('.issueCard').each(function () {
const id = $(this).data('status')+'|'+$(this).data('sprint')+'|'+$(this).data('version');
const $column = $(`[data-id='${id}']`);
if ($column.length) {
$column.append($(this));
}
else {
console.log ('no ='+id);
$(this).remove();
}
});
// Afficher le scrum après rangement
$('.scrumContainer').css('display', 'flex');
// Filtre
$('#statusFilter').on('change',showhide);
$('#sprintFilter').on('change',showhide);
$('#versionFilter').on('change',showhide);
$('#trackerFilter').on('change',showhide);
$('#categoryFilter').on('change',showhide);
showhide();
// issueBody
$('#toggleIssueBody').on('click',toogleIssueBody);
toogleIssueBody();
// Ajuste height
adjustHeight();
window.addEventListener("resize", adjustHeight);
});
function fetchAndRenderIssue(issueId) {
url='{{path('app_issue_view',{id:'xxx'})}}';
url=url.replace('xxx',issueId);
console.log(url);
$.ajax({
url: url,
method: 'GET',
dataType: 'html',
success: function (html) {
$('.issueContainer').html(html);
$('.issueContainer').show();
$('.scrumContainer').css('padding-left','750px');
$('.issueContainer').css('z-index','1000');
$('.issueDescription').animate({scrollTop: 0}, 0);
adjustHeight();
},
error: function (xhr, status, error) {
console.error('Erreur lors du chargement de lissue :', error);
$('.issueContainer').html('<div class="alert alert-danger">Erreur lors du chargement de lissue.</div>');
}
});
}
$(function () {
let $sourceContainer = null;
let $movedItem = null;
let originalIndex = null;
$('.versionBody').sortable({
connectWith: '.versionBody',
items: '.issueCard',
handle: '.issueId',
placeholder: 'issue-placeholder',
forcePlaceholderSize: true,
start: function (event, ui) {
$sourceContainer = ui.item.parent();
$movedItem = ui.item;
originalIndex = ui.item.index();
},
update: function (event, ui) {
// ❗ Se déclenche même pour tri dans la même colonne
if (!event.originalEvent) return; // ← ignorer les appels indirects
const $targetContainer = $(this);
const sourceId = $sourceContainer.data('id');
const targetId = $targetContainer.data('id');
// Ne pas dupliquer l'appel si cest un vrai "receive"
if (ui.sender) return;
sendUpdate(sourceId, targetId, $sourceContainer, $targetContainer, $movedItem, originalIndex);
},
receive: function (event, ui) {
const $targetContainer = $(this);
const sourceId = $sourceContainer.data('id');
const targetId = $targetContainer.data('id');
sendUpdate(sourceId, targetId, $sourceContainer, $targetContainer, $movedItem, originalIndex);
}
});
function sendUpdate(sourceId, targetId, $sourceContainer, $targetContainer, $movedItem, originalIndex) {
const sourceIssues = $sourceContainer.find('.issueCard').map(function () {
return $(this).data('id');
}).get();
const targetIssues = $targetContainer.find('.issueCard').map(function () {
return $(this).data('id');
}).get();
url='{{path('app_issue_order',{id:'xxx'})}}';
url=url.replace('xxx',$movedItem.data('id'));
$.ajax({
url: url,
method: 'POST',
data: {
source: sourceId,
target: targetId,
sourceIssues: sourceIssues,
targetIssues: targetIssues
},
success: function (response) {
console.log('Déplacement réussi', response);
},
error: function (xhr) {
console.error('Erreur AJAX', xhr);
// Annuler le déplacement
if ($movedItem && $sourceContainer) {
const items = $sourceContainer.children();
if (originalIndex >= items.length) {
$sourceContainer.append($movedItem);
} else {
$movedItem.insertBefore(items.eq(originalIndex));
}
}
}
});
}
});
</script>
{% endblock %}