Files
ninemine/templates/project/view.html.twig
2025-07-14 11:50:52 +02:00

771 lines
25 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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;
overflow:auto;
}
.issueContainer {
position:fixed;
width:750px;
}
.filtreContainer{
position:fixed;
display:flex;
width:300px;
flex-direction:column;
background-color: var(--bs-dark);
padding:5px;
z-index: 900;
}
.scrumContainer {
display:none;
padding-left:300px;
width:10000px;
}
.containerStatus{
display:flex;
//width:10000px;
overflow-x:scroll;
}
.statusCard {
width:300px;
padding: 15px 10px 0px 10px;
}
.statusCard 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;
}
.issueTitle{
zoom: 70%;
flex-grow: 1;
cursor: pointer;
line-height:18px;
}
.issueParent{
font-size:12px;
line-height:12px;
color:var(--bs-green);
}
.issueBody {
font-size:10px;
display:flex;
flex-direction: column;
border-top: 1px solid var(--bs-gray-100);
margin-top:5px;
padding-top:5px;
}
.issueChilds {
font-size:10px;
display:flex;
flex-direction: column;
border-top: 1px solid var(--bs-gray-100);
margin-top:5px;
padding-top:5px;
line-height:11px;
}
.issueChild {
display:flex;
cursor: pointer;
margin-top:5px;
}
.issueContainer table {
width:100%;
}
table {
width: 100%;
border-collapse: collapse;
font-family: sans-serif;
font-size: 14px;
}
th, td {
padding: 8px 12px;
border: 1px solid #ddd;
text-align: left;
}
@keyframes pulse {
0% { transform: scale(1); }
25% { transform: scale(1.1); }
50% { transform: scale(1); }
75% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.pulse-highlight {
animation: pulse 1.5s ease-in-out 1;
z-index: 1000;
position: relative;
}
.through {
text-decoration: line-through;
}
</style>
{% endblock %}
{% block body %}
<div class='issueContainer'>
</div>
<div class='filtreContainer'>
<label>Issue</label>
<input type="number" id="issueSearchInput" class="form-control" placeholder="Rechercher une issue" />
<a href="{{redmineUrl}}/projects/{{project.id}}/issues/new" target="_blank" class="btn btn-primary btn-sm"><i class="fas fa-file"></i> Nouvelle Demande</a>
<label style="margin-top:30px">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>
<label>Détail Issue</label>
<select id="detailFilter" class="select2 form-select" tabindex="-1" aria-hidden="true">
<option value="0">Non</option>
<option value="1">Oui</option>
</select>
<table style="margin-top:30px;">
{% for sprint in project.redmine.sprints|reverse %}
{% if sprint.story_points is defined and sprint.id not in project.hiddensprints %}
<tr><td style="padding:2px">{{sprint.name}}</td><td style="padding:2px; text-align: center;">{{sprint.story_points.total}}</td></tr>
{% endif %}
{% endfor %}
</table>
</div>
<div class='scrumContainer'>
<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 {{issue.color}} 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='issueTitle'>
{% if issue.parent %}
<div class='issueParent' data-id='{{issue.parent.id}}'>#{{issue.parent.id}} = {{issue.parent.redmine.subject}}</div>
{% endif %}
<div class='issueSubject'>
{{issue.redmine.subject}}
</div>
</div>
<div class='issueAction'>
<i class='fas fa-eye' onClick='viewIssue({{issue.id}})'></i>
<verysmall>{{issue.redmine.sprint.story_points}}</verysmall>
</div>
</div>
<div class='issueBody'>
<div><strong>Tracker =</strong> {{issue.redmine.tracker.name}}</div>
<div><strong>Catégorie =</strong> {{issue.redmine.category is defined?issue.redmine.category.name:''}}</div>
<div><strong>Affecté à =</strong> {{(issue.redmine.assigned_to is defined?issue.redmine.assigned_to.name:'')}}</div>
<div><strong>Créé le =</strong> {{ issue.redmine.created_on|date('d/m/Y H:i') }}</div>
<div><strong>Mis à jour le =</strong> {{ issue.redmine.updated_on|date('d/m/Y H:i') }}</div>
</div>
{% if issue.childs is not empty %}
<div class='issueChilds'>
{% for child in issue.childs %}
<div class='issueChild {{issue.redmine.status.is_closed?'through':''}}' data-id='{{child.id}}'>
<div>#{{child.id}}</div>
<div style='padding-left:5px;'>{{child.redmine.subject}}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endblock %}
{% block javascripts %}
<script>
const projectId = '{{ project.id }}';
const viewedIssueKey = `project_${projectId}_viewedIssue`;
const bodyIssueKey = `project_${projectId}_bodyIssue`;
const filterIds = [
'statusFilter',
'sprintFilter',
'versionFilter',
'trackerFilter',
'categoryFilter',
'detailFilter'
];
// Pulse
function highlightAndScroll($element) {
if (!$element || !$element.length) return;
if (!$element.is(':visible')) return;
// Scroll vertical (retourne une promesse)
const scrollTopPromise = new Promise(resolve => {
$('html, body').animate({
scrollTop: $element.offset().top - 100
}, 500, resolve);
});
// Scroll horizontal (retourne une promesse)
let paddingLeft = parseInt($('.scrumContainer').css('padding-left'), 315);
const scrollLeftPromise = new Promise(resolve => {
$('html, body').animate({
scrollLeft: $element.offset().left - paddingLeft
}, 500, resolve);
});
// Attendre que les deux scrolls soient terminés
Promise.all([scrollTopPromise, scrollLeftPromise]).then(() => {
$element.addClass('pulse-highlight');
setTimeout(() => {
$element.removeClass('pulse-highlight');
}, 1500); // Ajuster selon la durée de l'animation CSS
});
}
// Ranger les issues dans status / sprint / version
$(function () {
$('.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();
}
});
$('.scrumContainer').css('display', 'flex');
});
// Ajuster les dom en fonction de la window
function adjustHeight() {
console.log('adjustHeight');
let ele = document.querySelector(".issueDescription");
if (ele) {
const top = ele.getBoundingClientRect().top;
ele.style.height = (window.innerHeight - top) + "px";
}
ele = document.querySelector(".filtreContainer");
if (ele) {
const top = ele.getBoundingClientRect().top;
ele.style.height = (window.innerHeight - top) + "px";
}
}
window.addEventListener("resize", adjustHeight);
window.addEventListener("load", function () {
adjustHeight();
});
// Recherche Issue
$(function () {
$('#issueSearchInput').on('input', function () {
const value = $(this).val().trim();
if (!value || isNaN(value)) return;
const $target = $(`.issueCard[data-id="${value}"]`);
if ($target.length > 0) {
highlightAndScroll($target);
} else {
// Non trouvé → optionnel
// hideIssue();
console.warn('Issue non trouvée');
}
});
});
// Affichage Body Issue
function getVisibleIssueIds() {
const stored = localStorage.getItem(bodyIssueKey);
return stored ? JSON.parse(stored) : [];
}
function saveVisibleIssueIds(ids) {
localStorage.setItem(bodyIssueKey, JSON.stringify(ids));
}
// Parent / Child
$(document).on('click', '.issueParent', function () {
const value = $(this).data('id');
const $target = $(`.issueCard[data-id="${value}"]`);
if ($target.length > 0) {
highlightAndScroll($target);
}
});
$(document).on('click', '.issueChild', function () {
const value = $(this).data('id');
const $target = $(`.issueCard[data-id="${value}"]`);
if ($target.length > 0) {
highlightAndScroll($target);
}
});
// Toggle issueBody
$(document).on('click', '.issueSubject', function () {
const $card = $(this).closest('.issueCard');
const $body = $card.find('.issueBody');
const issueId = $card.data('id').toString();
let visibleIds = getVisibleIssueIds();
if ($body.is(':visible')) {
$body.slideUp(200);
visibleIds = visibleIds.filter(id => id !== issueId);
} else {
$body.slideDown(200);
if (!visibleIds.includes(issueId)) {
visibleIds.push(issueId);
}
}
saveVisibleIssueIds(visibleIds);
});
// Affichage Issue
function viewIssue(issueId) {
localStorage.setItem(viewedIssueKey, 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 hideIssue() {
localStorage.removeItem(viewedIssueKey);
$('.issueContainer').hide();
$('.scrumContainer').css('padding-left','300px');
}
// Filtre sur les issues et backup des filtres en localstorage
$(function () {
// Fonction pour construire une clé unique par projet
const getKey = (id) => `project_${projectId}_${id}`;
// Initialiser les sélections depuis localStorage
filterIds.forEach(id => {
const saved = localStorage.getItem(getKey(id));
if (saved) {
const values = JSON.parse(saved);
$(`#${id}`).val(values).trigger('change');
}
});
// Afficher / Cacher les issues
function showhide() {
// Backup des filtres en localstorage
filterIds.forEach(id => {
const selected = $(`#${id}`).val() || [];
localStorage.setItem(getKey(id), JSON.stringify(selected));
});
// 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();
});
// Filtre detail
selected = $('#detailFilter').val();
if(selected=="0")
$('.issueBody').hide();
else
$('.issueBody').show();
// Afficher l'issue
const savedIssueId = localStorage.getItem(viewedIssueKey);
if (savedIssueId) {
viewIssue(savedIssueId);
}
// Affiche bodyIssue
const visibleIds = getVisibleIssueIds();
visibleIds.forEach(id => {
const $card = $(`.issueCard[data-id="${id}"]`);
const $body = $card.find('.issueBody');
if ($body.length && !$body.is(':visible')) {
$body.show(); // ou .slideDown(200) si tu veux de l'anim
}
});
}
// Écouteurs sur tous les filtres
filterIds.forEach(id => {
$(`#${id}`).on('change', showhide);
});
// Lancer une première fois après initialisation
showhide();
});
// Déplacement des issues
$(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();
enableAutoScroll($(window)); // ou remplace par ton conteneur scrollable
},
stop: function () {
disableAutoScroll();
},
update: function (event, ui) {
console.log("UPDATE");
if (!event.originalEvent) return;
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: {
target: targetId,
targetIssues: targetIssues
},
success: function (response) {
console.log('Déplacement réussi', response);
},
error: function (xhr) {
console.log(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));
}
}
const message = xhr.responseJSON?.message || 'Une erreur est survenue lors de la requête.';
$('#ajaxErrorMessage').text(message);
const toast = new bootstrap.Toast(document.getElementById('ajaxErrorToast'), {
delay: 5000
});
toast.show();
}
});
}
});
let autoScrollInterval = null;
function enableAutoScroll($container) {
$(document).on('mousemove.autoScroll', function (e) {
const scrollMargin = 50; // px depuis le bord de la fenêtre
const scrollSpeed = 20; // px à chaque tick
const mouseY = e.clientY;
const windowHeight = window.innerHeight;
// Scroll vers le haut
if (mouseY < scrollMargin) {
clearInterval(autoScrollInterval);
autoScrollInterval = setInterval(() => {
$container.scrollTop($container.scrollTop() - scrollSpeed);
}, 50);
}
// Scroll vers le bas
else if (mouseY > windowHeight - scrollMargin) {
clearInterval(autoScrollInterval);
autoScrollInterval = setInterval(() => {
$container.scrollTop($container.scrollTop() + scrollSpeed);
}, 50);
} else {
clearInterval(autoScrollInterval);
}
});
}
function disableAutoScroll() {
$(document).off('mousemove.autoScroll');
clearInterval(autoScrollInterval);
}
</script>
{% endblock %}