Fix category management and remove import feature
- Fix category editing: Added GET /api/categories/:categoryId endpoint to fetch single category by ID, fixing 'Category not found' error when editing categories for active year - Fix picks handling: Filter out picks for deleted categories when submitting, loading, and calculating scores to prevent errors - Fix scores display: Always calculate scores from pickDetails instead of stored score value to ensure accuracy when categories are removed - Add picks cleanup: Automatically clean up picks for deleted categories in database when categories are deleted or scores are fetched - Fix winner clearing: Allow clearing winners by accepting empty/null winnerName in PUT /api/categories/:categoryId/winner - Enable category number editing: Made category number editable in edit modal with validation and conflict checking; automatically updates all related picks when category number changes - Add bulk category management: Added bulk select and delete functionality in Liste tab with select all checkbox and bulk delete button - Remove import feature: Completely removed PDF/image import functionality including OCR, file upload, and all related UI components - Update dependencies: Removed unused packages (tesseract.js, sharp, pdfjs-dist, canvas) after removing import feature - Improve file cleanup: Added retry logic for file deletion to handle Windows file locking issues
This commit is contained in:
+15
-60
@@ -892,18 +892,13 @@
|
||||
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
|
||||
<div>
|
||||
<h4 class="mb-1">Catégories Oscars</h4>
|
||||
<div class="text-muted">Importez les catégories depuis un PDF ou créez-les manuellement.</div>
|
||||
<div class="text-muted">Créez et gérez les catégories manuellement.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-pills admin-subtabs mt-3" id="adminCategoriesSubTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="admin-categories-subtab-import" data-bs-toggle="tab" data-bs-target="#admin-categories-pane-import" type="button" role="tab" aria-controls="admin-categories-pane-import" aria-selected="true">
|
||||
Import PDF
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="admin-categories-subtab-manual" data-bs-toggle="tab" data-bs-target="#admin-categories-pane-manual" type="button" role="tab" aria-controls="admin-categories-pane-manual" aria-selected="false">
|
||||
<button class="nav-link active" id="admin-categories-subtab-manual" data-bs-toggle="tab" data-bs-target="#admin-categories-pane-manual" type="button" role="tab" aria-controls="admin-categories-pane-manual" aria-selected="true">
|
||||
Création manuelle
|
||||
</button>
|
||||
</li>
|
||||
@@ -915,54 +910,8 @@
|
||||
</ul>
|
||||
|
||||
<div class="tab-content pt-3">
|
||||
<!-- Import PDF -->
|
||||
<div class="tab-pane fade show active" id="admin-categories-pane-import" role="tabpanel" aria-labelledby="admin-categories-subtab-import" tabindex="0">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xxl-7">
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<div class="fw-semibold mb-1">Importer depuis un PDF</div>
|
||||
<div class="text-muted small mb-3">
|
||||
Téléchargez le fichier PDF de la feuille de props Oscars. Le système extraira automatiquement les catégories et les nommés.
|
||||
</div>
|
||||
|
||||
<div id="categories-import-alert" class="alert d-none" role="alert"></div>
|
||||
|
||||
<form id="import-pdf-form">
|
||||
<div class="mb-3">
|
||||
<label for="categories-year" class="form-label">Année</label>
|
||||
<select class="form-select" id="categories-year" required>
|
||||
<option value="">Chargement...</option>
|
||||
</select>
|
||||
<div class="form-text">Année des Oscars pour ces catégories.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="pdf-file" class="form-label">Fichier PDF</label>
|
||||
<input type="file" class="form-control" id="pdf-file" accept=".pdf,application/pdf" required>
|
||||
<div class="form-text">Format attendu: feuille de props Oscars avec catégories numérotées.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-warning">Importer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xxl-5">
|
||||
<div class="border rounded p-3 bg-white">
|
||||
<div class="fw-semibold mb-2">Astuce</div>
|
||||
<div class="text-muted">
|
||||
Le PDF doit contenir des catégories numérotées (1., 2., etc.) suivies de leurs nommés.
|
||||
Vérifiez la liste après import pour corriger si nécessaire.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual creation -->
|
||||
<div class="tab-pane fade" id="admin-categories-pane-manual" role="tabpanel" aria-labelledby="admin-categories-subtab-manual" tabindex="0">
|
||||
<div class="tab-pane fade show active" id="admin-categories-pane-manual" role="tabpanel" aria-labelledby="admin-categories-subtab-manual" tabindex="0">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xxl-7">
|
||||
<div class="border rounded p-3 bg-light">
|
||||
@@ -1023,11 +972,17 @@
|
||||
<div class="fw-semibold">Catégories importées</div>
|
||||
<div class="text-muted small">Gérez les catégories existantes</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select" id="list-year-filter" style="width: 150px;">
|
||||
<option value="">Toutes les années</option>
|
||||
</select>
|
||||
<button type="button" id="refresh-categories-list" class="btn btn-outline-secondary">Rafraîchir</button>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select" id="list-year-filter" style="width: 150px;">
|
||||
<option value="">Toutes les années</option>
|
||||
</select>
|
||||
<button type="button" id="refresh-categories-list" class="btn btn-outline-secondary">Rafraîchir</button>
|
||||
<button type="button" id="bulk-delete-categories-btn" class="btn btn-sm btn-danger" disabled>Supprimer</button>
|
||||
</div>
|
||||
<div id="categories-selection-text" class="text-muted small" style="display: none;">
|
||||
<span id="bulk-delete-count">0</span> catégorie(s) sélectionnée(s)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1279,7 +1234,7 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit_category_number" class="form-label">Numéro de catégorie</label>
|
||||
<input type="number" class="form-control" id="edit_category_number" readonly>
|
||||
<input type="number" class="form-control" id="edit_category_number" min="1" max="50" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
|
||||
+143
-74
@@ -2096,38 +2096,9 @@ window.onload = async function () {
|
||||
applyWinnerFormFromCache();
|
||||
|
||||
// Categories year dropdowns
|
||||
const categoriesYearSelect = document.getElementById('categories-year');
|
||||
const manualYearSelect = document.getElementById('manual-year');
|
||||
const listYearFilterSelect = document.getElementById('list-year-filter');
|
||||
|
||||
// Populate PDF import year dropdown
|
||||
if (categoriesYearSelect) {
|
||||
const previousCategoriesYear = categoriesYearSelect.value;
|
||||
categoriesYearSelect.innerHTML = '';
|
||||
|
||||
if (cleanedYears.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'Aucune année (ajoute un film)';
|
||||
opt.disabled = true;
|
||||
categoriesYearSelect.appendChild(opt);
|
||||
} else {
|
||||
cleanedYears.forEach((y) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(y);
|
||||
opt.textContent = String(y);
|
||||
categoriesYearSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
const desired = previousCategoriesYear && cleanedYears.map(String).includes(previousCategoriesYear)
|
||||
? previousCategoriesYear
|
||||
: (activeYear && cleanedYears.map(String).includes(String(activeYear)))
|
||||
? String(activeYear)
|
||||
: cleanedYears.length > 0 ? String(cleanedYears[0]) : '';
|
||||
if (desired) categoriesYearSelect.value = desired;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate manual creation year dropdown
|
||||
if (manualYearSelect) {
|
||||
const previousManualYear = manualYearSelect.value;
|
||||
@@ -2761,14 +2732,13 @@ window.onload = async function () {
|
||||
}
|
||||
|
||||
// Categories management
|
||||
const importPdfForm = document.getElementById('import-pdf-form');
|
||||
const createCategoryForm = document.getElementById('create-category-form');
|
||||
const refreshCategoriesListBtn = document.getElementById('refresh-categories-list');
|
||||
const categoriesListContainer = document.getElementById('categories-list-container');
|
||||
const listYearFilter = document.getElementById('list-year-filter');
|
||||
|
||||
function showCategoriesAlert(message, type = 'info') {
|
||||
const alertEl = document.getElementById('categories-import-alert') || document.getElementById('categories-manual-alert');
|
||||
const alertEl = document.getElementById('categories-manual-alert');
|
||||
if (!alertEl) return;
|
||||
alertEl.className = `alert alert-${type}`;
|
||||
alertEl.textContent = message;
|
||||
@@ -2778,44 +2748,6 @@ window.onload = async function () {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
if (importPdfForm) {
|
||||
importPdfForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const year = parseYear(document.getElementById('categories-year').value);
|
||||
const fileInput = document.getElementById('pdf-file');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file || !year) {
|
||||
showCategoriesAlert('Veuillez remplir tous les champs', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdf', file);
|
||||
formData.append('year', year);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/categories/import-pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || 'Import failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
showCategoriesAlert(`Import réussi: ${data.categories.length} catégories importées`, 'success');
|
||||
fileInput.value = '';
|
||||
if (refreshCategoriesListBtn) refreshCategoriesListBtn.click();
|
||||
} catch (err) {
|
||||
showCategoriesAlert('Erreur: ' + err.message, 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (createCategoryForm) {
|
||||
createCategoryForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -2897,6 +2829,9 @@ window.onload = async function () {
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;">
|
||||
<input type="checkbox" class="form-check-input" id="select-all-categories" title="Sélectionner tout">
|
||||
</th>
|
||||
<th>#</th>
|
||||
<th>Nom</th>
|
||||
<th>Nommés</th>
|
||||
@@ -2906,6 +2841,9 @@ window.onload = async function () {
|
||||
<tbody>
|
||||
${categories.map(cat => `
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input category-checkbox" data-category-id="${cat._id}" data-category-name="${cat.categoryName}">
|
||||
</td>
|
||||
<td>${cat.categoryNumber}</td>
|
||||
<td><strong>${cat.categoryName}</strong></td>
|
||||
<td>
|
||||
@@ -2923,6 +2861,9 @@ window.onload = async function () {
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Setup bulk selection handlers
|
||||
setupBulkCategorySelection();
|
||||
} catch (err) {
|
||||
categoriesListContainer.innerHTML = `<div class="alert alert-danger">Erreur: ${err.message}</div>`;
|
||||
}
|
||||
@@ -2930,17 +2871,21 @@ window.onload = async function () {
|
||||
|
||||
window.editCategory = async function(categoryId) {
|
||||
try {
|
||||
// Fetch category details
|
||||
const res = await fetch(`/api/categories/list`, {
|
||||
// Fetch category directly by ID
|
||||
const res = await fetch(`/api/categories/${categoryId}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to load categories');
|
||||
if (res.status === 404) {
|
||||
showResponse('danger', 'Catégorie non trouvée');
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load category');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const category = data.categories.find(c => c._id === categoryId);
|
||||
const category = data.category;
|
||||
|
||||
if (!category) {
|
||||
showResponse('danger', 'Catégorie non trouvée');
|
||||
@@ -3019,10 +2964,27 @@ window.onload = async function () {
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryNumber = parseInt(document.getElementById('edit_category_number').value);
|
||||
if (!categoryNumber || categoryNumber < 1 || categoryNumber > 50) {
|
||||
if (alertEl) {
|
||||
alertEl.className = 'alert alert-warning';
|
||||
alertEl.textContent = 'Le numéro de catégorie doit être entre 1 et 50';
|
||||
alertEl.classList.remove('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Store button state before async operation
|
||||
const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer';
|
||||
|
||||
try {
|
||||
saveCategoryChangesBtn.disabled = true;
|
||||
const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer';
|
||||
saveCategoryChangesBtn.textContent = 'Enregistrement...';
|
||||
|
||||
// Hide any previous alerts
|
||||
if (alertEl) {
|
||||
alertEl.classList.add('d-none');
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/categories/${categoryId}`, {
|
||||
method: 'PUT',
|
||||
@@ -3031,6 +2993,7 @@ window.onload = async function () {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
categoryNumber,
|
||||
categoryName,
|
||||
nominees
|
||||
})
|
||||
@@ -3051,12 +3014,19 @@ window.onload = async function () {
|
||||
// Reload list
|
||||
await loadCategoriesList();
|
||||
} catch (err) {
|
||||
// Show error in modal alert
|
||||
if (alertEl) {
|
||||
alertEl.className = 'alert alert-danger';
|
||||
alertEl.textContent = 'Erreur: ' + err.message;
|
||||
alertEl.classList.remove('d-none');
|
||||
// Scroll to alert to make it visible
|
||||
alertEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} else {
|
||||
// Fallback to main response if alert element not found
|
||||
showResponse('danger', 'Erreur: ' + err.message);
|
||||
}
|
||||
} finally {
|
||||
// Always restore button state
|
||||
saveCategoryChangesBtn.disabled = false;
|
||||
saveCategoryChangesBtn.textContent = oldText;
|
||||
}
|
||||
@@ -3079,5 +3049,104 @@ window.onload = async function () {
|
||||
loadCategoriesList();
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk category selection and deletion
|
||||
function setupBulkCategorySelection() {
|
||||
const selectAllCheckbox = document.getElementById('select-all-categories');
|
||||
const bulkDeleteBtn = document.getElementById('bulk-delete-categories-btn');
|
||||
const bulkDeleteCount = document.getElementById('bulk-delete-count');
|
||||
const selectionText = document.getElementById('categories-selection-text');
|
||||
const categoryCheckboxes = document.querySelectorAll('.category-checkbox');
|
||||
|
||||
if (!selectAllCheckbox || !bulkDeleteBtn) return;
|
||||
|
||||
// Select all checkbox handler
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
categoryCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = this.checked;
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
});
|
||||
|
||||
// Individual checkbox handlers
|
||||
categoryCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
updateBulkDeleteButton();
|
||||
// Update select all checkbox state
|
||||
const allChecked = Array.from(categoryCheckboxes).every(cb => cb.checked);
|
||||
const someChecked = Array.from(categoryCheckboxes).some(cb => cb.checked);
|
||||
selectAllCheckbox.checked = allChecked;
|
||||
selectAllCheckbox.indeterminate = someChecked && !allChecked;
|
||||
});
|
||||
});
|
||||
|
||||
// Update bulk delete button state
|
||||
function updateBulkDeleteButton() {
|
||||
const selected = Array.from(categoryCheckboxes).filter(cb => cb.checked);
|
||||
const count = selected.length;
|
||||
if (bulkDeleteCount) {
|
||||
bulkDeleteCount.textContent = count;
|
||||
}
|
||||
bulkDeleteBtn.disabled = count === 0;
|
||||
if (selectionText) {
|
||||
selectionText.style.display = count > 0 ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk delete handler
|
||||
bulkDeleteBtn.addEventListener('click', async function() {
|
||||
const selected = Array.from(categoryCheckboxes).filter(cb => cb.checked);
|
||||
if (selected.length === 0) return;
|
||||
|
||||
const categoryIds = selected.map(cb => cb.dataset.categoryId);
|
||||
const categoryNames = selected.map(cb => cb.dataset.categoryName);
|
||||
|
||||
const confirmMessage = `Êtes-vous sûr de vouloir supprimer ${categoryIds.length} catégorie(s)?\n\n${categoryNames.join('\n')}\n\nCette action est irréversible.`;
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
try {
|
||||
bulkDeleteBtn.disabled = true;
|
||||
const oldText = bulkDeleteBtn.textContent;
|
||||
bulkDeleteBtn.textContent = 'Suppression...';
|
||||
|
||||
// Delete categories one by one
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const categoryId of categoryIds) {
|
||||
try {
|
||||
const res = await fetch(`/api/categories/${categoryId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error deleting category ${categoryId}:`, err);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
showResponse('success', `${successCount} catégorie(s) supprimée(s)${errorCount > 0 ? `, ${errorCount} erreur(s)` : ''}`);
|
||||
await loadCategoriesList();
|
||||
} else {
|
||||
showResponse('danger', `Erreur lors de la suppression: ${errorCount} erreur(s)`);
|
||||
}
|
||||
} catch (err) {
|
||||
showResponse('danger', 'Erreur: ' + err.message);
|
||||
} finally {
|
||||
bulkDeleteBtn.disabled = false;
|
||||
bulkDeleteBtn.textContent = oldText;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize button state
|
||||
updateBulkDeleteButton();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+42
-133
@@ -39,9 +39,8 @@
|
||||
|
||||
let categories = [];
|
||||
let myPicks = {};
|
||||
let savedPicks = {}; // Track saved state to detect changes
|
||||
let currentYear = null;
|
||||
let hasUnsavedChanges = false;
|
||||
let isSaving = false; // Prevent multiple simultaneous saves
|
||||
|
||||
async function fetchActiveYear() {
|
||||
try {
|
||||
@@ -133,20 +132,17 @@
|
||||
const data = await res.json();
|
||||
if (data.pick && data.pick.picks) {
|
||||
myPicks = {};
|
||||
savedPicks = {}; // Reset saved state
|
||||
// Create a map of existing category numbers
|
||||
const existingCategoryNumbers = new Set(categories.map(c => c.categoryNumber));
|
||||
|
||||
// Only load picks for categories that still exist
|
||||
data.pick.picks.forEach(p => {
|
||||
myPicks[p.categoryNumber] = p.selectedNominee;
|
||||
savedPicks[p.categoryNumber] = p.selectedNominee; // Store saved state
|
||||
if (existingCategoryNumbers.has(p.categoryNumber)) {
|
||||
myPicks[p.categoryNumber] = p.selectedNominee;
|
||||
}
|
||||
});
|
||||
hasUnsavedChanges = false;
|
||||
renderCategories();
|
||||
updateProgress();
|
||||
updateUnsavedChangesIndicator();
|
||||
} else {
|
||||
// No picks saved yet
|
||||
savedPicks = {};
|
||||
hasUnsavedChanges = false;
|
||||
updateUnsavedChangesIndicator();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading picks:', err);
|
||||
@@ -227,71 +223,41 @@
|
||||
|
||||
// Re-render to update the display
|
||||
renderCategories();
|
||||
checkForUnsavedChanges();
|
||||
|
||||
// Auto-save after selection change
|
||||
autoSavePicks();
|
||||
});
|
||||
});
|
||||
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function checkForUnsavedChanges() {
|
||||
// Compare current picks with saved picks
|
||||
const currentKeys = Object.keys(myPicks).sort();
|
||||
const savedKeys = Object.keys(savedPicks).sort();
|
||||
|
||||
// Check if counts differ
|
||||
if (currentKeys.length !== savedKeys.length) {
|
||||
hasUnsavedChanges = true;
|
||||
updateUnsavedChangesIndicator();
|
||||
return;
|
||||
async function autoSavePicks() {
|
||||
// Clear any existing timeout to reset the debounce
|
||||
if (autoSavePicks.timeout) {
|
||||
clearTimeout(autoSavePicks.timeout);
|
||||
}
|
||||
|
||||
// Check if any values differ or keys are different
|
||||
const allKeys = new Set([...currentKeys, ...savedKeys]);
|
||||
for (const key of allKeys) {
|
||||
if (myPicks[key] !== savedPicks[key]) {
|
||||
hasUnsavedChanges = true;
|
||||
updateUnsavedChangesIndicator();
|
||||
// Debounce: wait for user to stop clicking before saving
|
||||
autoSavePicks.timeout = setTimeout(async () => {
|
||||
// Skip if already saving (will retry after current save completes)
|
||||
if (isSaving) {
|
||||
// Retry after a short delay
|
||||
setTimeout(() => autoSavePicks(), 200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No changes detected
|
||||
hasUnsavedChanges = false;
|
||||
updateUnsavedChangesIndicator();
|
||||
}
|
||||
|
||||
function updateUnsavedChangesIndicator() {
|
||||
const submitBtn = document.getElementById('submit-picks');
|
||||
const banner = document.getElementById('unsaved-changes-banner');
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
// Enable button and change style
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.classList.add('btn-warning');
|
||||
submitBtn.classList.remove('btn-primary');
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
await submitPicks(true); // Pass true for silent mode (no alert)
|
||||
} catch (err) {
|
||||
console.error('Auto-save failed:', err);
|
||||
// Show error alert only on failure
|
||||
showAlert('Erreur lors de l\'enregistrement automatique: ' + err.message, 'danger');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
// Show banner
|
||||
if (banner) {
|
||||
banner.classList.remove('d-none');
|
||||
if (!banner.classList.contains('show')) {
|
||||
banner.classList.add('show');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Disable button and change style
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.classList.remove('btn-warning');
|
||||
submitBtn.classList.add('btn-primary');
|
||||
}
|
||||
// Hide banner
|
||||
if (banner) {
|
||||
banner.classList.add('d-none');
|
||||
banner.classList.remove('show');
|
||||
}
|
||||
}
|
||||
}, 250); // Reduced to 250ms for better responsiveness
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
@@ -300,18 +266,20 @@
|
||||
document.getElementById('picks-progress').textContent = `${completed} / ${total}`;
|
||||
}
|
||||
|
||||
async function submitPicks() {
|
||||
async function submitPicks(silent = false) {
|
||||
const picks = Object.keys(myPicks).map(catNum => ({
|
||||
categoryNumber: parseInt(catNum),
|
||||
selectedNominee: myPicks[catNum]
|
||||
}));
|
||||
|
||||
if (picks.length === 0) {
|
||||
showAlert('Veuillez sélectionner au moins un choix', 'warning');
|
||||
return;
|
||||
// Allow empty picks to be saved (to clear all selections)
|
||||
// Only show warning in non-silent mode if user explicitly tries to submit with no picks
|
||||
if (picks.length === 0 && !silent) {
|
||||
// Don't block - allow saving empty picks to clear selections
|
||||
}
|
||||
|
||||
if (picks.length < categories.length) {
|
||||
// Skip confirmation dialog in silent mode (auto-save)
|
||||
if (!silent && picks.length < categories.length) {
|
||||
if (!confirm(`Vous n'avez sélectionné que ${picks.length} sur ${categories.length} catégories. Voulez-vous enregistrer quand même?`)) {
|
||||
return;
|
||||
}
|
||||
@@ -336,11 +304,9 @@
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// Update saved state after successful save
|
||||
savedPicks = { ...myPicks };
|
||||
hasUnsavedChanges = false;
|
||||
updateUnsavedChangesIndicator();
|
||||
showAlert('Vos choix ont été enregistrés avec succès!', 'success');
|
||||
if (!silent) {
|
||||
showAlert('Vos choix ont été enregistrés avec succès!', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error submitting picks:', err);
|
||||
showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger');
|
||||
@@ -348,7 +314,6 @@
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('submit-picks')?.addEventListener('click', submitPicks);
|
||||
document.getElementById('load-my-picks')?.addEventListener('click', () => {
|
||||
loadMyPicks();
|
||||
showAlert('Choix rechargés', 'info');
|
||||
@@ -599,7 +564,6 @@
|
||||
<th>Utilisateur</th>
|
||||
<th class="text-center" style="width: 80px;">Score</th>
|
||||
<th class="text-center" style="width: 70px;">Total</th>
|
||||
<th class="text-center" style="width: 70px;">%</th>
|
||||
<th class="text-center" style="width: 100px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -607,7 +571,6 @@
|
||||
${validScores.map((score, index) => {
|
||||
// Use the pre-calculated actualScore (always use it, even if 0)
|
||||
const actualScore = score.actualScore !== undefined ? score.actualScore : (score.score || 0);
|
||||
const percentage = totalCategories > 0 ? Math.round((actualScore / totalCategories) * 100) : 0;
|
||||
const rank = index + 1;
|
||||
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
|
||||
return `
|
||||
@@ -616,7 +579,6 @@
|
||||
<td>${score.userName}</td>
|
||||
<td class="text-center"><strong class="text-success">${actualScore}</strong></td>
|
||||
<td class="text-center text-muted small">${totalCategories}</td>
|
||||
<td class="text-center"><strong>${percentage}%</strong></td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-sm btn-outline-primary view-picks-btn" data-user-id="${score.userId}" data-user-name="${score.userName}" data-score-index="${index}">
|
||||
Détails
|
||||
@@ -746,7 +708,6 @@
|
||||
}
|
||||
|
||||
// Event listeners for winners tab
|
||||
document.getElementById('calculate-scores')?.addEventListener('click', calculateScores);
|
||||
document.getElementById('refresh-winners')?.addEventListener('click', () => {
|
||||
fetchWinnersCategories();
|
||||
showAlert('Données rafraîchies', 'info');
|
||||
@@ -755,14 +716,6 @@
|
||||
// Load scores when scores tab is shown
|
||||
const scoresTab = document.getElementById('tab-scores');
|
||||
if (scoresTab) {
|
||||
scoresTab.addEventListener('show.bs.tab', (e) => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm('Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir changer d\'onglet? Vos modifications seront perdues.')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
scoresTab.addEventListener('shown.bs.tab', async () => {
|
||||
await fetchScores();
|
||||
});
|
||||
@@ -771,14 +724,6 @@
|
||||
// Load winners when admin tab is shown
|
||||
const winnersTab = document.getElementById('tab-winners');
|
||||
if (winnersTab) {
|
||||
winnersTab.addEventListener('show.bs.tab', (e) => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm('Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir changer d\'onglet? Vos modifications seront perdues.')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
winnersTab.addEventListener('shown.bs.tab', async () => {
|
||||
await fetchWinnersCategories();
|
||||
// Auto-refresh scores when entering the winners tab (silent mode)
|
||||
@@ -792,44 +737,8 @@
|
||||
showAlert('Scores rafraîchis', 'info');
|
||||
});
|
||||
|
||||
// Warn before leaving page with unsaved changes
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault();
|
||||
e.returnValue = 'Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page?';
|
||||
return e.returnValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept navigation links to warn about unsaved changes
|
||||
document.addEventListener('click', (e) => {
|
||||
// Check if clicking on a navigation link
|
||||
const link = e.target.closest('a[href]');
|
||||
if (!link) return;
|
||||
|
||||
// Skip if it's the same page or a hash link
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||
|
||||
// Skip if it's the submit button or other action buttons
|
||||
if (link.closest('#pane-my-picks') || link.id === 'submit-picks') return;
|
||||
|
||||
// Check for unsaved changes
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault();
|
||||
const confirmed = confirm('Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page? Vos modifications seront perdues.');
|
||||
if (confirmed) {
|
||||
hasUnsavedChanges = false; // Allow navigation
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load
|
||||
(async () => {
|
||||
// Initialize button state (disabled by default)
|
||||
updateUnsavedChangesIndicator();
|
||||
|
||||
currentYear = await fetchActiveYear();
|
||||
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
|
||||
await fetchCategories();
|
||||
|
||||
@@ -668,16 +668,23 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
bonPicksHeader.style.display = showBonPicksColumn ? '' : 'none';
|
||||
}
|
||||
|
||||
// Sort by total points (descending), then by watched ratio
|
||||
// Sort by total points (descending)
|
||||
stats.sort((a, b) => {
|
||||
const pointsDiff = (b.totalPoints || 0) - (a.totalPoints || 0);
|
||||
if (pointsDiff !== 0) return pointsDiff;
|
||||
return parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio);
|
||||
return (b.totalPoints || 0) - (a.totalPoints || 0);
|
||||
});
|
||||
|
||||
// Clear previous rows to prevent duplication
|
||||
userTableBody.innerHTML = '';
|
||||
|
||||
// Get total movies count (same for all users in the same year)
|
||||
const totalMovies = stats.length > 0 ? (stats[0].totalMoviesCount || 0) : 0;
|
||||
|
||||
// Update Films column header with total
|
||||
const filmsHeader = document.getElementById('films-header');
|
||||
if (filmsHeader && totalMovies > 0) {
|
||||
filmsHeader.innerHTML = `Films <small class="text-muted" style="font-weight: normal;">(${totalMovies})</small>`;
|
||||
}
|
||||
|
||||
stats.forEach((userStat, index) => {
|
||||
const userRow = document.createElement('tr');
|
||||
const rank = index + 1;
|
||||
@@ -712,15 +719,11 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
pointsTd.className = 'text-center';
|
||||
pointsTd.innerHTML = `<strong class="text-success">${userStat.totalPoints || 0}</strong>`;
|
||||
|
||||
const ratioTd = document.createElement('td');
|
||||
ratioTd.textContent = String(userStat.watchedRatio ?? '');
|
||||
|
||||
userRow.appendChild(rankTd);
|
||||
userRow.appendChild(nameTd);
|
||||
userRow.appendChild(countTd);
|
||||
userRow.appendChild(picksTd);
|
||||
userRow.appendChild(pointsTd);
|
||||
userRow.appendChild(ratioTd);
|
||||
userTableBody.appendChild(userRow);
|
||||
});
|
||||
|
||||
|
||||
@@ -168,15 +168,9 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="load-my-picks" class="btn btn-sm btn-outline-secondary">Recharger</button>
|
||||
<button type="button" id="submit-picks" class="btn btn-sm btn-primary">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="unsaved-changes-banner" class="alert alert-warning alert-dismissible fade d-none" role="alert">
|
||||
<strong>⚠️ Modifications en attente:</strong> Vous avez des modifications non enregistrées. N'oubliez pas d'enregistrer vos choix avant de quitter cette page.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div id="categories-container">
|
||||
<div class="text-center py-3 text-muted small">Chargement des catégories…</div>
|
||||
</div>
|
||||
@@ -206,7 +200,6 @@
|
||||
<span class="text-muted small ms-2"><span id="winners-progress">0 / 0</span> gagnants marqués</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="calculate-scores" class="btn btn-sm btn-success">Calculer</button>
|
||||
<button type="button" id="refresh-winners" class="btn btn-sm btn-outline-secondary">Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,10 +111,9 @@
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Nom</th>
|
||||
<th scope="col">Films</th>
|
||||
<th scope="col" id="films-header">Films</th>
|
||||
<th scope="col" id="bon-picks-header">Bon picks</th>
|
||||
<th scope="col">Points</th>
|
||||
<th scope="col">Ratio</th>
|
||||
<th scope="col" class="text-center">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-table-body">
|
||||
|
||||
Reference in New Issue
Block a user