osm-labo/templates/admin/wiki_archived_proposals.html.twig
2025-08-31 17:57:28 +02:00

679 lines
No EOL
36 KiB
Twig

{% extends 'base.html.twig' %}
{% block title %}Propositions archivées OSM{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.vote-bar {
height: 24px;
border-radius: 4px;
overflow: hidden;
display: flex;
margin-bottom: 10px;
}
.vote-approve {
background-color: #28a745;
height: 100%;
}
.vote-abstain {
background-color: #ffc107;
height: 100%;
}
.vote-oppose {
background-color: #dc3545;
height: 100%;
}
.vote-count {
font-size: 0.85rem;
font-weight: bold;
color: white;
text-align: center;
padding: 2px 5px;
}
.proposal-card {
margin-bottom: 1.5rem;
transition: transform 0.2s;
}
.proposal-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.proposal-metadata {
font-size: 0.85rem;
color: #6c757d;
}
.voter-badge {
display: inline-block;
margin-right: 5px;
margin-bottom: 5px;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
}
.voter-approve {
background-color: rgba(40, 167, 69, 0.2);
border: 1px solid rgba(40, 167, 69, 0.4);
}
.voter-abstain {
background-color: rgba(255, 193, 7, 0.2);
border: 1px solid rgba(255, 193, 7, 0.4);
}
.voter-oppose {
background-color: rgba(220, 53, 69, 0.2);
border: 1px solid rgba(220, 53, 69, 0.4);
}
.stats-card {
transition: transform 0.2s;
}
.stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.stats-value {
font-size: 2rem;
font-weight: bold;
}
.stats-label {
font-size: 0.9rem;
color: #6c757d;
}
.top-voters-table th, .top-voters-table td {
padding: 0.5rem;
}
.vote-duration {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
background-color: #e9ecef;
margin-top: 5px;
font-size: 0.85rem;
}
.comment-text {
font-style: italic;
color: #495057;
background-color: #f8f9fa;
padding: 8px;
border-radius: 4px;
margin-top: 5px;
margin-bottom: 10px;
border-left: 3px solid #dee2e6;
}
.chart-container {
position: relative;
height: 300px;
margin-bottom: 20px;
}
</style>
{% endblock %}
{% block body %}
<div class="container mt-4">
{% include 'admin/_wiki_navigation.html.twig' %}
<h1>Propositions archivées OpenStreetMap</h1>
<p class="lead">Analyse des votes sur les propositions archivées du wiki OSM</p>
{% if last_updated %}
<div class="alert alert-info">
<div class="row align-items-center">
<div class="col-md-6">
<i class="bi bi-info-circle"></i> Dernière mise à jour : {{ last_updated|date('d/m/Y H:i') }}
</div>
<div class="col-md-6 text-end">
<form class="d-inline-flex align-items-center" method="get" action="{{ path('app_admin_wiki_archived_proposals') }}">
<div class="me-2">
<label for="limit" class="me-2">Limiter à:</label>
<select name="limit" id="limit" class="form-select form-select-sm d-inline-block" style="width: auto;">
<option value="">Toutes les propositions</option>
<option value="10" {% if limit == 10 %}selected{% endif %}>10 propositions</option>
<option value="20" {% if limit == 20 %}selected{% endif %}>20 propositions</option>
<option value="50" {% if limit == 50 %}selected{% endif %}>50 propositions</option>
<option value="100" {% if limit == 100 %}selected{% endif %}>100 propositions</option>
{% if limit and limit not in [10, 20, 50, 100] %}
<option value="{{ limit }}" selected>{{ limit }} propositions</option>
{% endif %}
</select>
</div>
<input type="hidden" name="refresh" value="1">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-clockwise"></i> Rafraîchir les données
</button>
</form>
</div>
</div>
{% if limit %}
<div class="mt-2">
<small class="text-muted">
<i class="bi bi-info-circle"></i> Les données sont limitées à {{ limit }} propositions.
<a href="{{ path('app_admin_wiki_archived_proposals') }}">Voir toutes les propositions</a>
</small>
</div>
{% endif %}
</div>
{% endif %}
{% if statistics %}
<div class="card mb-4">
<div class="card-header">
<h2>Statistiques globales</h2>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="stats-value">{{ statistics.total_proposals }}</div>
<div class="stats-label">Propositions analysées</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="stats-value">{{ statistics.total_votes }}</div>
<div class="stats-label">Votes au total</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="stats-value">{{ statistics.avg_votes_per_proposal }}</div>
<div class="stats-label">Votes par proposition (moyenne)</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="stats-value">{{ statistics.unique_voters }}</div>
<div class="stats-label">Votants uniques</div>
</div>
</div>
</div>
</div>
{% if statistics.avg_vote_duration_days is defined %}
<div class="row mt-3">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="stats-value">{{ statistics.avg_vote_duration_days }}</div>
<div class="stats-label">Durée moyenne des votes (jours)</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if statistics.status_distribution is defined and statistics.status_distribution|length > 0 %}
<div class="row mt-4">
<div class="col-md-6">
<h4>Répartition par statut</h4>
<div class="chart-container">
<canvas id="statusChart"></canvas>
</div>
</div>
<div class="col-md-6">
<h4>Détail des statuts</h4>
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Statut</th>
<th>Nombre</th>
<th>Pourcentage</th>
</tr>
</thead>
<tbody>
{% for status, count in statistics.status_distribution %}
<tr>
<td>{{ status }}</td>
<td>{{ count }}</td>
<td>{{ (count / statistics.total_proposals * 100)|round(1) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="row mt-4">
<div class="col-12">
<h4>Répartition des années des propositions</h4>
<div class="chart-container">
<canvas id="yearDistributionChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h2>Contributeurs les plus actifs</h2>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover top-voters-table">
<thead>
<tr>
<th>#</th>
<th>Utilisateur</th>
<th>Total votes</th>
<th>Approbations</th>
<th>Abstentions</th>
<th>Oppositions</th>
<th>Répartition</th>
</tr>
</thead>
<tbody>
{% for voter in statistics.top_voters %}
<tr>
<td>{{ loop.index }}</td>
<td>
<a href="https://wiki.openstreetmap.org/wiki/User:{{ voter.username }}" target="_blank">
{{ voter.username }}
</a>
</td>
<td>{{ voter.total }}</td>
<td>{{ voter.approve }}</td>
<td>{{ voter.abstain }}</td>
<td>{{ voter.oppose }}</td>
<td>
<div class="vote-bar">
{% if voter.approve > 0 %}
<div class="vote-approve" style="width: {{ (voter.approve / voter.total * 100)|round }}%">
<span class="vote-count">{{ (voter.approve / voter.total * 100)|round }}%</span>
</div>
{% endif %}
{% if voter.abstain > 0 %}
<div class="vote-abstain" style="width: {{ (voter.abstain / voter.total * 100)|round }}%">
<span class="vote-count">{{ (voter.abstain / voter.total * 100)|round }}%</span>
</div>
{% endif %}
{% if voter.oppose > 0 %}
<div class="vote-oppose" style="width: {{ (voter.oppose / voter.total * 100)|round }}%">
<span class="vote-count">{{ (voter.oppose / voter.total * 100)|round }}%</span>
</div>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="mb-0">Liste des propositions archivées</h2>
<div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm filter-btn active" data-filter="all">Toutes</button>
<button type="button" class="btn btn-outline-success btn-sm filter-btn" data-filter="approved">Approuvées</button>
<button type="button" class="btn btn-outline-danger btn-sm filter-btn" data-filter="rejected">Rejetées</button>
<button type="button" class="btn btn-outline-warning btn-sm filter-btn" data-filter="neutral">Neutres</button>
</div>
</div>
</div>
<div class="card-body">
<div class="row" id="proposals-container">
{% for proposal in proposals %}
{% set total_votes = proposal.votes.approve.count + proposal.votes.oppose.count + proposal.votes.abstain.count %}
{% set is_approved = proposal.votes.approve.count > proposal.votes.oppose.count %}
{% set is_rejected = proposal.votes.approve.count < proposal.votes.oppose.count %}
{% set is_neutral = proposal.votes.approve.count == proposal.votes.oppose.count %}
<div class="col-md-6 mb-4 proposal-item {% if is_approved %}approved{% elseif is_rejected %}rejected{% else %}neutral{% endif %}">
<div class="card proposal-card h-100 {% if is_approved %}border-success{% elseif is_rejected %}border-danger{% else %}border-warning{% endif %}">
<div class="card-header {% if is_approved %}bg-success text-white{% elseif is_rejected %}bg-danger text-white{% else %}bg-warning{% endif %}">
<h5 class="card-title mb-0">
<a href="{{ proposal.url }}" target="_blank" class="text-white">
{{ proposal.title }}
</a>
</h5>
</div>
<div class="card-body">
<div class="proposal-metadata mb-3">
{% if proposal.proposer %}
<div><strong>Proposé par :</strong> {{ proposal.proposer }}</div>
{% endif %}
{% if proposal.last_modified %}
<div><strong>Dernière modification :</strong> {{ proposal.last_modified }}</div>
{% endif %}
<div><strong>Sections :</strong> {{ proposal.section_count }}</div>
<div><strong>Liens :</strong> {{ proposal.link_count }}</div>
<div><strong>Mots :</strong> {{ proposal.word_count }}</div>
{% if proposal.votes.duration_days is defined %}
<div class="mt-2">
<span class="vote-duration">
<i class="bi bi-calendar-range"></i>
<strong>Durée du vote :</strong> {{ proposal.votes.duration_days }} jours
({{ proposal.votes.first_vote }}{{ proposal.votes.last_vote }})
</span>
</div>
{% endif %}
</div>
{% if total_votes > 0 %}
<h6>Résultats des votes ({{ total_votes }} votes)</h6>
<div class="vote-bar">
{% if proposal.votes.approve.count > 0 %}
<div class="vote-approve" style="width: {{ proposal.approve_percentage }}%">
<span class="vote-count">{{ proposal.votes.approve.count }} ({{ proposal.approve_percentage }}%)</span>
</div>
{% endif %}
{% if proposal.votes.abstain.count > 0 %}
<div class="vote-abstain" style="width: {{ proposal.abstain_percentage }}%">
<span class="vote-count">{{ proposal.votes.abstain.count }} ({{ proposal.abstain_percentage }}%)</span>
</div>
{% endif %}
{% if proposal.votes.oppose.count > 0 %}
<div class="vote-oppose" style="width: {{ proposal.oppose_percentage }}%">
<span class="vote-count">{{ proposal.votes.oppose.count }} ({{ proposal.oppose_percentage }}%)</span>
</div>
{% endif %}
</div>
<div class="mt-3">
<div class="accordion" id="votersAccordion{{ loop.index }}">
<div class="accordion-item">
<h2 class="accordion-header" id="headingVoters{{ loop.index }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseVoters{{ loop.index }}" aria-expanded="false"
aria-controls="collapseVoters{{ loop.index }}">
Voir les votants
</button>
</h2>
<div id="collapseVoters{{ loop.index }}" class="accordion-collapse collapse"
aria-labelledby="headingVoters{{ loop.index }}"
data-bs-parent="#votersAccordion{{ loop.index }}">
<div class="accordion-body">
{% if proposal.votes.approve.users|length > 0 %}
<div class="mb-2">
<strong>Approbations ({{ proposal.votes.approve.count }}):</strong>
<div>
{% for user in proposal.votes.approve.users %}
<div class="mb-2">
<span class="voter-badge voter-approve">{{ user.username }}</span>
{% if user.comment is defined and user.comment is not empty %}
<div class="comment-text">{{ user.comment }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if proposal.votes.abstain.users|length > 0 %}
<div class="mb-2">
<strong>Abstentions ({{ proposal.votes.abstain.count }}):</strong>
<div>
{% for user in proposal.votes.abstain.users %}
<div class="mb-2">
<span class="voter-badge voter-abstain">{{ user.username }}</span>
{% if user.comment is defined and user.comment is not empty %}
<div class="comment-text">{{ user.comment }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if proposal.votes.oppose.users|length > 0 %}
<div>
<strong>Oppositions ({{ proposal.votes.oppose.count }}):</strong>
<div>
{% for user in proposal.votes.oppose.users %}
<div class="mb-2">
<span class="voter-badge voter-oppose">{{ user.username }}</span>
{% if user.comment is defined and user.comment is not empty %}
<div class="comment-text">{{ user.comment }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="alert alert-secondary">
Aucun vote trouvé pour cette proposition.
</div>
{% endif %}
</div>
<div class="card-footer">
<a href="{{ proposal.url }}" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up-right"></i> Voir sur le wiki
</a>
</div>
</div>
</div>
{% else %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i> Aucune proposition archivée n'a été trouvée.
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Filtering functionality
const filterButtons = document.querySelectorAll('.filter-btn');
const proposalItems = document.querySelectorAll('.proposal-item');
filterButtons.forEach(button => {
button.addEventListener('click', function() {
// Remove active class from all buttons
filterButtons.forEach(btn => btn.classList.remove('active'));
// Add active class to clicked button
this.classList.add('active');
const filter = this.getAttribute('data-filter');
// Show/hide proposals based on filter
proposalItems.forEach(item => {
if (filter === 'all') {
item.style.display = 'block';
} else if (filter === 'approved' && item.classList.contains('approved')) {
item.style.display = 'block';
} else if (filter === 'rejected' && item.classList.contains('rejected')) {
item.style.display = 'block';
} else if (filter === 'neutral' && item.classList.contains('neutral')) {
item.style.display = 'block';
} else {
item.style.display = 'none';
}
});
});
});
// Initialize status distribution chart if it exists
const statusChartCanvas = document.getElementById('statusChart');
if (statusChartCanvas) {
// Get status distribution data from the template
const statusDistribution = {{ statistics.status_distribution|json_encode|raw }};
if (statusDistribution && Object.keys(statusDistribution).length > 0) {
const labels = Object.keys(statusDistribution);
const data = Object.values(statusDistribution);
// Generate colors for each status
const backgroundColors = [
'#28a745', // Approved - green
'#dc3545', // Rejected - red
'#ffc107', // Voting/Proposed - yellow
'#6c757d', // Abandoned/Inactive - gray
'#17a2b8', // Other statuses - blue
'#6610f2', // Other statuses - purple
'#fd7e14', // Other statuses - orange
'#20c997', // Other statuses - teal
'#e83e8c' // Other statuses - pink
];
// Create the chart
new Chart(statusChartCanvas, {
type: 'pie',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: backgroundColors.slice(0, labels.length),
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
font: {
size: 12
}
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = Math.round((value / total) * 100);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
}
}
// Initialize year distribution chart
const yearChartCanvas = document.getElementById('yearDistributionChart');
if (yearChartCanvas) {
// Get proposals data from the template
const proposals = {{ proposals|json_encode|raw }};
if (proposals && proposals.length > 0) {
// Extract years from last_modified dates
const yearCounts = {};
proposals.forEach(proposal => {
if (proposal.last_modified) {
// Extract year from the date string (format: "DD Month YYYY")
const yearMatch = proposal.last_modified.match(/\d{4}$/);
if (yearMatch) {
const year = yearMatch[0];
yearCounts[year] = (yearCounts[year] || 0) + 1;
}
}
});
// Sort years chronologically
const sortedYears = Object.keys(yearCounts).sort();
const counts = sortedYears.map(year => yearCounts[year]);
// Generate a color gradient for the bars
const colors = sortedYears.map((year, index) => {
// Create a gradient from blue to green
const ratio = index / (sortedYears.length - 1 || 1);
return `rgba(${Math.round(33 + (20 * ratio))}, ${Math.round(150 + (50 * ratio))}, ${Math.round(243 - (100 * ratio))}, 0.7)`;
});
// Create the chart
new Chart(yearChartCanvas, {
type: 'bar',
data: {
labels: sortedYears,
datasets: [{
label: 'Nombre de propositions',
data: counts,
backgroundColor: colors,
borderColor: colors.map(color => color.replace('0.7', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0
},
title: {
display: true,
text: 'Nombre de propositions'
}
},
x: {
title: {
display: true,
text: 'Année'
}
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: function(context) {
return `Année ${context[0].label}`;
},
label: function(context) {
const count = context.raw;
return count > 1 ? `${count} propositions` : `${count} proposition`;
}
}
}
}
}
});
}
}
});
</script>
{% endblock %}