This commit is contained in:
2025-07-23 22:35:07 +02:00
parent f93b9b13fb
commit 686b614cd6
6 changed files with 326 additions and 98 deletions

View File

@ -4,6 +4,7 @@ namespace App\Controller;
use App\Form\IssueType;
use App\Repository\IssueRepository;
use App\Repository\ProjectRepository;
use App\Service\RedmineService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -23,6 +24,22 @@ class IssueController extends AbstractController
$this->redmineService = $redmineService;
}
#[Route('/user/issue', name: 'app_issue_list')]
public function listIssues(ProjectRepository $projectRepository, IssueRepository $issueRepository): Response
{
$projects = $projectRepository->findAll();
foreach ($projects as $project) {
$redmine = $this->redmineService->getProject($project->getId(), $this->getParameter('redmineApikey'));
$project->setRedmine($redmine);
$this->redmineService->majProjectIssues($project, $this->getParameter('redmineApikey'), false);
}
return $this->render('issue/list.html.twig', [
'usemenu' => true,
'issues' => $issueRepository->findIssues(null, false, $this->getUser()),
]);
}
#[Route('/user/issue/{id}', name: 'app_issue_view')]
public function viewIssue(int $id, IssueRepository $issueRepository): Response
{

View File

@ -4,6 +4,7 @@ namespace App\Repository;
use App\Entity\Issue;
use App\Entity\Project;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@ -17,13 +18,13 @@ class IssueRepository extends ServiceEntityRepository
parent::__construct($registry, Issue::class);
}
public function findIssues(?Project $project, bool $closed = false): array
public function findIssues(?Project $project, bool $closed = false, ?User $user = null): array
{
$criteria=[];
if($project) {
$criteria = [];
if ($project) {
$criteria = ['project' => $project];
}
if (!$closed) {
$criteria['isClosed'] = false;
}

View File

@ -59,6 +59,7 @@
{% if app.user %}
{% 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>
<a class="nav-link px-2" href="{{path('app_project_report',{month:202501})}}"><i class="fas fa-file-alt fa-2x"></i></a>
{% endif %}
<a class="nav-link px-2" href="{{path('app_user_profil')}}"><img src="{{asset(app.user.avatar)}}" class="avatar"></a>
<a class="nav-link px-2" href="{{path('app_logout')}}"><i class="fa-solid fa-right-from-bracket fa-2x"></i></a>

View File

@ -0,0 +1,157 @@
{% extends 'base.html.twig' %}
{% block localstyle %}
<style>
</style>
{% endblock %}
{% block title %}
= Liste des Issues
{% endblock %}
{% block body %}
<h1>
Liste des Issues
</h1>
<div class="d-flex">
<div style="width:320px; padding: 0px 10px 0px 0px">
<div style="margin-bottom: 1em;">
<label for="projectFilter"><strong>Filtrer par projet :</strong></label><br>
<select id="projectFilter" multiple style="width: 300px; height: 150px;" class="select2">
{# Options ajoutées dynamiquement #}
</select>
</div>
<div style="margin-bottom: 1em;">
<label for="statusFilter"><strong>Filtrer par statut :</strong></label><br>
<select id="statusFilter" multiple style="width: 300px; height: 150px;" class="select2">
{# Options JS dynamiques #}
</select>
</div>
<div style="margin-bottom: 1em;">
<label for="categoryFilter"><strong>Filtrer par catégorie :</strong></label><br>
<select id="categoryFilter" multiple style="width: 300px; height: 150px;" class="select2">
{# Options JS dynamiques #}
</select>
</div>
</div>
<div style="flex-grow:1">
<div class="dataTable_wrapper">
<table class="table table-striped table-bordered table-hover" id="dataTables" style="width:100%; zoom:80%;">
<thead>
<tr>
<th width="70px">Id</th>
<th width="130px">Projet</th>
<th>Nom</th>
<th>Statut</th>
<th>Catégorie</th>
</tr>
</thead>
<tbody>
{% for issue in issues %}
<tr>
<td>#{{issue.id}}</td>
<td>{{issue.project.title}}</td>
<td>{{issue.redmine.subject}}</td>
<td>{{issue.redmine.status.name}}</td>
<td>{{issue.redmine.category is defined?issue.redmine.category.name:'Aucune'}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block localscript %}
<script>
$(document).ready(function() {
const table = $('#dataTables').DataTable({
columnDefs: [
{ "targets": "no-sort", "orderable": false },
{ "targets": "no-string", "type" : "num" }
],
responsive: true,
iDisplayLength: 100,
order: [[ 0, "desc" ]]
});
// Étape 1 : Récupérer les filtres possible
const projectSet = new Set();
table.column(1).data().each(function(value) {
projectSet.add(value);
});
const statusSet = new Set();
table.column(3).data().each(function(value) {
statusSet.add(value);
});
const categorySet = new Set();
table.column(4).data().each(function(value) {
categorySet.add(value);
});
console.log(categorySet);
// Étape 2 : Remplir dynamiquement les filtres
const $selectProject = $('#projectFilter');
Array.from(projectSet).sort().forEach(function(project) {
const option = $('<option>', {
value: project,
text: project,
selected: false
});
$selectProject.append(option);
});
const $statusSelect = $('#statusFilter');
Array.from(statusSet).sort().forEach(function(status) {
const option = $('<option>', {
value: status,
text: status,
selected: false
});
$statusSelect.append(option);
});
const $categorySelect = $('#categoryFilter');
Array.from(categorySet).sort().forEach(function(category) {
const option = $('<option>', {
value: category,
text: category,
selected: false
});
$categorySelect.append(option);
});
// Étape 3 : Ajouter le filtre personnalisé à DataTables
$.fn.dataTable.ext.search.push(function(settings, data, dataIndex) {
const selectedProjects = $('#projectFilter').val();
const selectedStatuses = $('#statusFilter').val();
const selectedCategories = $('#categoryFilter').val();
const project = data[1]; // Colonne "Projet"
const status = data[3]; // Colonne "Statut"
const category = data[4]; // Colonne "Categorie"
const projectMatch = !selectedProjects || selectedProjects.length === 0 || selectedProjects.includes(project);
const statusMatch = !selectedStatuses || selectedStatuses.length === 0 || selectedStatuses.includes(status);
const categoryMatch = !selectedCategories || selectedCategories.length === 0 || selectedCategories.includes(category);
return projectMatch && statusMatch && categoryMatch;
});
// Étape 4 : Rafraîchir le tableau quand le select change
$selectProject.on('change', function() {
table.draw();
});
$statusSelect.on('change', function() {
table.draw();
});
$categorySelect.on('change', function() {
table.draw();
});
});
</script>
{% endblock %}

View File

@ -6,31 +6,31 @@
<h1>{{title}}</h1>
<a href="{{ path(routesubmit) }}" class="btn btn-success">Ajouter</a>
<div class="dataTable_wrapper">
<table class="table table-striped table-bordered table-hover" id="dataTables" style="width:100%">
<thead>
<div class="dataTable_wrapper">
<table class="table table-striped table-bordered table-hover" id="dataTables" style="width:100%">
<thead>
<tr>
<th width="100px" class="no-sort">Action</th>
<th width="70px">Logo</th>
<th width="70px">Modifier</th>
<th>Nom</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<th width="100px" class="no-sort">Action</th>
<th width="70px">Logo</th>
<th width="70px">Modifier</th>
<th>Nom</th>
<td>
<a href="{{ path(routeupdate,{id:project.id}) }}" class="me-2"><i class="fas fa-file fa-2x"></i></a>
<a href="{{ path('app_admin_project_reclone',{id:project.id}) }}" onclick="return confirm('Es-tu sûr de vouloir reinitialiser et reindexer le projet ?');"><i class="fas fa-rotate-right fa-2x"></i></a>
</td>
<td><img class="avatar" src="{{ asset(project.logo)}}"></td>
<td>{{project.title}}</td>
<td>{{project.redmine.updated_on}}</td>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<a href="{{ path(routeupdate,{id:project.id}) }}" class="me-2"><i class="fas fa-file fa-2x"></i></a>
<a href="{{ path('app_admin_project_reclone',{id:project.id}) }}" onclick="return confirm('Es-tu sûr de vouloir reinitialiser et reindexer le projet ?');"><i class="fas fa-rotate-right fa-2x"></i></a>
</td>
<td><img class="avatar" src="{{ asset(project.logo)}}"></td>
<td>{{project.title}}</td>
<td>{{project.redmine.updated_on}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% block localscript %}

View File

@ -1,57 +1,45 @@
{% extends 'base.html.twig' %}
{% block body %}
{% for status in project.redmine.issue_statuses %}
<div class='statusCard statusCard{{status.id}}'>
<h2>{{ status.name }}</h2>
<div class="d-flex">
<div style="width:300px; padding: 0px 10px">
<div id="monthSelectorContainer" style="margin-bottom: 1em;">
<label for="monthSelector"><strong>Choisir un mois :</strong></label><br>
<input type="month" id="monthSelector" style="width: 100%;" value="{{ date|date('Y-m') }}">
</div>
{% for sprint in project.redmine.sprints|reverse %}
<div class='sprintCard sprintCard{{sprint.id}}'>
<!-- <h3>Sprint = {{ sprint.name }}</h3> -->
<div id="statusCardSelectorContainer" style="margin-bottom: 1em;">
<label for="statusCardSelector"><strong>Afficher/Masquer les statuts :</strong></label><br>
<select id="statusCardSelector" multiple size="15" style="width: 100%;">
{# Options JS dynamiques #}
</select>
</div>
<button id="copyReportBtn" class="btn btn-primary" style="margin-top: 10px; width: 100%;">📋 Copier le rapport</button>
<div id="copyStatusMsg" style="font-size: 0.9em; color: green; display: none; margin-top: 5px;">
✅ Copié dans le presse-papiers !
</div>
</div>
{% for version in project.redmine.versions|reverse %}
{% if version.id not in project.hiddenversions %}
<div class='versionCard versionCard{{version.id}}'>
<!-- <h4>Version = {{ version.name }}</h4> -->
<div class='versionBody' data-id='{{status.id}}|{{sprint.id}}|{{version.id}}'></div>
</div>
{% endif %}
{% endfor %}
<div class='versionCard versionCardNone'>
<!-- <h4>Version = Aucune</h4> -->
<div class='versionBody' data-id='{{status.id}}|{{sprint.id}}|'></div>
<div class="report" style="padding-left: 10px">
{% for status in project.redmine.issue_statuses %}
<div class='statusCard statusCard{{status.id}}'>
<h2>{{ status.name }}</h2>
<div class='statusBody' data-id='{{status.id}}'>
</div>
<hr>
</div>
{% endfor %}
<div class='sprintCard sprintCardNone'>
<!-- <h3>Sprint Aucun</h3> -->
{% for version in project.redmine.versions|reverse %}
<div class='versionCard versionCard{{version.id}}'>
<!-- <h4>Version = {{ version.name }}</h4> -->
<div class='versionBody' data-id='{{status.id}}||{{version.id}}'></div>
{% for issue in issues %}
{% if issue.redmine.updated_on|date('Ymd') >= date|date('Ymd') or (issue.redmine.closed_on is not null and issue.redmine.closed_on|date('Ymd') >= date|date('Ymd')) %}
<div class="issueCard" 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}}'>
#{{issue.id}} = {{issue.redmine.subject}}
</div>
{% endfor %}
<div class='versionCard versionCardNone'>
<!-- <h4>Version = Aucune</h4> -->
<div class='versionBody' data-id='{{status.id}}||'></div>
</div>
</div>
<hr>
{% endif %}
{% endfor %}
</div>
{% endfor %}
{% for issue in issues %}
{% if issue.redmine.updated_on|date('Ymd') >= date|date('Ymd') or (issue.redmine.closed_on is not null and issue.redmine.closed_on|date('Ymd') >= date|date('Ymd')) %}
<div class="issueCard {{issue.color}} 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}}'>
#{{issue.id}} = {{issue.redmine.subject}}
</div>
{% endif %}
{% endfor %}
</div>
{% endblock %}
{% block javascripts %}
@ -59,7 +47,7 @@
// Ranger les issues dans status / sprint / version
$(function () {
$('.issueCard').each(function () {
const id = $(this).data('status')+'|'+$(this).data('sprint')+'|'+$(this).data('version');
const id = $(this).data('status');
const $column = $(`[data-id='${id}']`);
if ($column.length) {
$column.append($(this));
@ -71,39 +59,19 @@
});
$('.scrumContainer').css('display', 'flex');
// Étape 1 : Masquer les versionCard sans enfant visible dans versionBody
document.querySelectorAll('.versionCard').forEach(card => {
const versionBody = card.querySelector('.versionBody');
const children = Array.from(versionBody?.children || []);
// Étape 1 : Masquer les statusCard sans enfant visible dans statusBody
document.querySelectorAll('.statusCard').forEach(card => {
const statusBody = card.querySelector('.statusBody');
const children = Array.from(statusBody?.children || []);
const hasVisibleChild = children.some(child => child.offsetParent !== null);
if (!hasVisibleChild) {
card.style.display = 'none';
card.remove();
}
});
// Étape 2 : Masquer les sprintCard sans versionCard visible
document.querySelectorAll('.sprintCard').forEach(sprint => {
const visibleVersion = Array.from(sprint.querySelectorAll('.versionCard'))
.some(card => card.offsetParent !== null);
if (!visibleVersion) {
sprint.style.display = 'none';
}
});
// Étape 3 : Masquer les statusCard sans sprintCard visible
document.querySelectorAll('.statusCard').forEach(status => {
const visibleSprint = Array.from(status.querySelectorAll('.sprintCard'))
.some(sprint => sprint.offsetParent !== null);
if (!visibleSprint) {
status.style.display = 'none';
}
});
// Étape 4 : Trier les issues restantes par data-id dans chaque statusCard
// Étape 2 : Trier les issues restantes par data-id dans chaque statusCard
document.querySelectorAll('.statusCard').forEach(statusCard => {
const issues = Array.from(statusCard.querySelectorAll('.issueCard'));
@ -114,7 +82,7 @@
visibleIssues.sort((a, b) => {
const idA = a.getAttribute('data-id');
const idB = b.getAttribute('data-id');
return idA.localeCompare(idB, undefined, { numeric: true, sensitivity: 'base' });
return idB.localeCompare(idA, undefined, { numeric: true, sensitivity: 'base' });
});
// Replacer les issues triées dans leur parent immédiat
@ -123,6 +91,90 @@
parent.appendChild(issue);
});
});
// Étape 3 : Générer la liste des statusCards visibles dans le sélecteur multiple
const selector = document.getElementById('statusCardSelector');
const statusCards = Array.from(document.querySelectorAll('.statusCard'));
statusCards.forEach(card => {
const statusName = card.querySelector('h2')?.textContent || 'Statut inconnu';
const statusId = card.className.match(/statusCard(\d+)/)?.[1];
if (statusId) {
const option = document.createElement('option');
option.value = statusId;
option.textContent = statusName;
option.selected = card.style.display !== 'none'; // Sélectionner si visible
selector.appendChild(option);
}
});
// Étape 4 : Afficher/Masquer les statusCard selon les options sélectionnées
selector.addEventListener('change', function () {
const selectedValues = Array.from(this.selectedOptions).map(opt => opt.value);
statusCards.forEach(card => {
const statusId = card.className.match(/statusCard(\d+)/)?.[1];
if (!statusId) return;
if (selectedValues.includes(statusId)) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
});
// Étape 5 : Copier le contenu de .report au clic sur le bouton
document.getElementById('copyReportBtn').addEventListener('click', function () {
const reportEl = document.querySelector('.report');
if (!reportEl) return;
// Créer une sélection virtuelle du contenu HTML visible
const range = document.createRange();
range.selectNodeContents(reportEl);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
try {
// Exécuter la commande de copie
const successful = document.execCommand('copy');
if (successful) {
const msg = document.getElementById('copyStatusMsg');
msg.style.display = 'inline';
setTimeout(() => {
msg.style.display = 'none';
}, 2000);
} else {
alert('Échec de la copie.');
}
} catch (err) {
alert('Erreur lors de la copie : ' + err);
}
// Nettoyer la sélection
selection.removeAllRanges();
});
// Étape 6 : Redirection sur changement de mois
document.getElementById('monthSelector').addEventListener('change', function () {
const selected = this.value; // format YYYY-MM
if (!selected) return;
const parts = selected.split('-');
if (parts.length !== 2) return;
const year = parts[0];
const month = parts[1];
const targetMonth = year + month;
// Redirection vers la route avec le nouveau mois
window.location.href = '{{ path("app_project_report", {"month": "REPLACE_ME"}) }}'.replace('REPLACE_ME', targetMonth);
});
});
</script>
{% endblock %}