912 lines
30 KiB
HTML
912 lines
30 KiB
HTML
{% extends "layout.html" %}
|
|
|
|
{% block content %}
|
|
<div class="management-container sprint-management-container">
|
|
<!-- Header Section -->
|
|
<div class="management-header sprint-header">
|
|
<h1>🏃♂️ Sprint Management</h1>
|
|
<div class="management-controls sprint-controls">
|
|
<!-- View Switcher -->
|
|
<div class="view-switcher">
|
|
<button class="view-btn active" data-view="active">Active Sprints</button>
|
|
<button class="view-btn" data-view="all">All Sprints</button>
|
|
<button class="view-btn" data-view="planning">Planning</button>
|
|
<button class="view-btn" data-view="completed">Completed</button>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="management-actions sprint-actions">
|
|
<button id="add-sprint-btn" class="btn btn-primary">+ New Sprint</button>
|
|
<button id="refresh-sprints" class="btn btn-secondary">🔄 Refresh</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sprint Statistics -->
|
|
<div class="management-stats sprint-stats">
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="total-sprints">0</div>
|
|
<div class="stat-label">Total Sprints</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="active-sprints">0</div>
|
|
<div class="stat-label">Active</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="completed-sprints">0</div>
|
|
<div class="stat-label">Completed</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="total-tasks">0</div>
|
|
<div class="stat-label">Total Tasks</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sprint Grid -->
|
|
<div class="management-grid sprint-grid" id="sprint-grid">
|
|
<!-- Sprint cards will be populated here -->
|
|
</div>
|
|
|
|
<!-- Loading and Error States -->
|
|
<div id="loading-indicator" class="loading-spinner" style="display: none;">
|
|
<div class="spinner"></div>
|
|
<p>Loading sprints...</p>
|
|
</div>
|
|
|
|
<div id="error-message" class="error-alert" style="display: none;">
|
|
<p>Failed to load sprints. Please try again.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sprint Modal -->
|
|
<div id="sprint-modal" class="modal" style="display: none;">
|
|
<div class="modal-content large">
|
|
<div class="modal-header">
|
|
<h2 id="modal-title">Sprint Details</h2>
|
|
<span class="close" onclick="closeSprintModal()">×</span>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="sprint-form">
|
|
<input type="hidden" id="sprint-id">
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="sprint-name">Sprint Name *</label>
|
|
<input type="text" id="sprint-name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="sprint-status">Status</label>
|
|
<select id="sprint-status">
|
|
<option value="PLANNING">Planning</option>
|
|
<option value="ACTIVE">Active</option>
|
|
<option value="COMPLETED">Completed</option>
|
|
<option value="CANCELLED">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="sprint-description">Description</label>
|
|
<textarea id="sprint-description" rows="3"></textarea>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="sprint-project">Project (Optional)</label>
|
|
<select id="sprint-project">
|
|
<option value="">Company-wide Sprint</option>
|
|
{% for project in available_projects %}
|
|
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="sprint-capacity">Capacity (Hours)</label>
|
|
<input type="number" id="sprint-capacity" min="0" step="1">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="sprint-start-date">Start Date *</label>
|
|
<div class="hybrid-date-input">
|
|
<input type="date" id="sprint-start-date-native" class="date-input-native" required>
|
|
<input type="text" id="sprint-start-date" class="date-input-formatted" required placeholder="{{ "YYYY-MM-DD" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "ISO" else "MM/DD/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "US" else "DD/MM/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") in ["EU", "UK"] else "Mon, Dec 25, 2024" }}">
|
|
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('sprint-start-date')" title="Open calendar">📅</button>
|
|
</div>
|
|
<div class="date-error" id="sprint-start-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="sprint-end-date">End Date *</label>
|
|
<div class="hybrid-date-input">
|
|
<input type="date" id="sprint-end-date-native" class="date-input-native" required>
|
|
<input type="text" id="sprint-end-date" class="date-input-formatted" required placeholder="{{ "YYYY-MM-DD" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "ISO" else "MM/DD/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "US" else "DD/MM/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") in ["EU", "UK"] else "Mon, Dec 25, 2024" }}">
|
|
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('sprint-end-date')" title="Open calendar">📅</button>
|
|
</div>
|
|
<div class="date-error" id="sprint-end-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="sprint-goal">Sprint Goal</label>
|
|
<textarea id="sprint-goal" rows="3" placeholder="What is the main objective of this sprint?"></textarea>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" onclick="closeSprintModal()">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveSprint()">Save Sprint</button>
|
|
<button type="button" class="btn btn-danger" onclick="deleteSprint()" id="delete-sprint-btn" style="display: none;">Delete Sprint</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Styles -->
|
|
<style>
|
|
.sprint-management-container {
|
|
padding: 1rem;
|
|
max-width: 100%;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.sprint-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
flex-wrap: wrap;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.sprint-header h1 {
|
|
margin: 0;
|
|
color: #333;
|
|
}
|
|
|
|
.sprint-controls {
|
|
display: flex;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
|
|
.view-switcher {
|
|
display: flex;
|
|
background: #f8f9fa;
|
|
border-radius: 6px;
|
|
padding: 2px;
|
|
}
|
|
|
|
.view-btn {
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
background: transparent;
|
|
color: #495057;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
transition: all 0.2s;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.view-btn.active {
|
|
background: #007bff;
|
|
color: white;
|
|
}
|
|
|
|
.view-btn:hover:not(.active) {
|
|
background: #e9ecef;
|
|
color: #212529;
|
|
}
|
|
|
|
.sprint-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.sprint-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.stat-card {
|
|
background: white;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-number {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
color: #007bff;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.9rem;
|
|
color: #666;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.sprint-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
|
gap: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
|
|
.sprint-card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.sprint-name {
|
|
font-size: 1.25rem;
|
|
font-weight: bold;
|
|
margin: 0;
|
|
color: #333;
|
|
}
|
|
|
|
.status-badge {
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 20px;
|
|
}
|
|
|
|
.sprint-dates {
|
|
font-size: 0.9rem;
|
|
color: #666;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.sprint-progress {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
|
|
.sprint-metrics {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.metric {
|
|
text-align: center;
|
|
padding: 0.5rem;
|
|
background: #f8f9fa;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.metric-number {
|
|
font-weight: bold;
|
|
color: #007bff;
|
|
}
|
|
|
|
.metric-label {
|
|
font-size: 0.75rem;
|
|
color: #666;
|
|
}
|
|
|
|
.sprint-goal {
|
|
font-size: 0.9rem;
|
|
color: #666;
|
|
font-style: italic;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
|
|
|
|
/* Hybrid Date Input Styles */
|
|
.hybrid-date-input {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.hybrid-date-input.compact {
|
|
display: inline-flex;
|
|
}
|
|
|
|
.date-input-native {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: calc(100% - 35px); /* Leave space for calendar button */
|
|
height: 100%;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
z-index: 2;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.date-input-formatted {
|
|
flex: 1;
|
|
padding: 0.5rem;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
background: white;
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
.calendar-picker-btn {
|
|
background: #f8f9fa;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
padding: 0.5rem;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
transition: background-color 0.2s;
|
|
z-index: 3;
|
|
position: relative;
|
|
}
|
|
|
|
.calendar-picker-btn:hover {
|
|
background: #e9ecef;
|
|
}
|
|
|
|
.calendar-picker-btn.compact {
|
|
padding: 0.375rem 0.5rem;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.hybrid-date-input.compact .date-input-formatted {
|
|
padding: 0.375rem;
|
|
font-size: 12px;
|
|
width: 100px;
|
|
flex: none;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.sprint-metrics {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
// User preferences for date formatting
|
|
const USER_DATE_FORMAT = '{{ g.user.preferences.date_format if g.user.preferences else "ISO" }}';
|
|
|
|
// Date formatting utility function
|
|
function formatUserDate(dateString) {
|
|
if (!dateString) return '';
|
|
|
|
const date = new Date(dateString);
|
|
if (isNaN(date.getTime())) return '';
|
|
|
|
switch (USER_DATE_FORMAT) {
|
|
case 'US':
|
|
return date.toLocaleDateString('en-US'); // MM/DD/YYYY
|
|
case 'EU':
|
|
case 'UK':
|
|
return date.toLocaleDateString('en-GB'); // DD/MM/YYYY
|
|
case 'Readable':
|
|
return date.toLocaleDateString('en-US', {
|
|
weekday: 'short',
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
}); // Mon, Dec 25, 2024
|
|
case 'ISO':
|
|
default:
|
|
return date.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
}
|
|
}
|
|
|
|
// Date input formatting function - formats ISO date for user input
|
|
function formatDateForInput(isoDateString) {
|
|
if (!isoDateString) return '';
|
|
|
|
const date = new Date(isoDateString);
|
|
if (isNaN(date.getTime())) return '';
|
|
|
|
return formatUserDate(isoDateString);
|
|
}
|
|
|
|
// Date parsing function - converts user-formatted date to ISO format
|
|
function parseUserDate(dateString) {
|
|
if (!dateString || dateString.trim() === '') return null;
|
|
|
|
const trimmed = dateString.trim();
|
|
let date;
|
|
|
|
switch (USER_DATE_FORMAT) {
|
|
case 'US': // MM/DD/YYYY
|
|
const usParts = trimmed.split('/');
|
|
if (usParts.length === 3) {
|
|
const month = parseInt(usParts[0], 10);
|
|
const day = parseInt(usParts[1], 10);
|
|
const year = parseInt(usParts[2], 10);
|
|
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year > 1900) {
|
|
date = new Date(year, month - 1, day);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'EU':
|
|
case 'UK': // DD/MM/YYYY
|
|
const euParts = trimmed.split('/');
|
|
if (euParts.length === 3) {
|
|
const day = parseInt(euParts[0], 10);
|
|
const month = parseInt(euParts[1], 10);
|
|
const year = parseInt(euParts[2], 10);
|
|
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year > 1900) {
|
|
date = new Date(year, month - 1, day);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'Readable': // Mon, Dec 25, 2024
|
|
date = new Date(trimmed);
|
|
break;
|
|
|
|
case 'ISO': // YYYY-MM-DD
|
|
default:
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
date = new Date(trimmed);
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (!date || isNaN(date.getTime())) {
|
|
return null;
|
|
}
|
|
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
// Date validation function
|
|
function validateDateInput(inputElement, errorElement) {
|
|
const value = inputElement.value.trim();
|
|
if (!value) {
|
|
errorElement.style.display = 'none';
|
|
return true;
|
|
}
|
|
|
|
const parsedDate = parseUserDate(value);
|
|
if (!parsedDate) {
|
|
let expectedFormat;
|
|
switch (USER_DATE_FORMAT) {
|
|
case 'US': expectedFormat = 'MM/DD/YYYY'; break;
|
|
case 'EU':
|
|
case 'UK': expectedFormat = 'DD/MM/YYYY'; break;
|
|
case 'Readable': expectedFormat = 'Mon, Dec 25, 2024'; break;
|
|
case 'ISO':
|
|
default: expectedFormat = 'YYYY-MM-DD'; break;
|
|
}
|
|
errorElement.textContent = `Invalid date format. Expected: ${expectedFormat}`;
|
|
errorElement.style.display = 'block';
|
|
return false;
|
|
}
|
|
|
|
errorElement.style.display = 'none';
|
|
return true;
|
|
}
|
|
|
|
// Date range validation function
|
|
function validateDateRange(startElement, endElement, startErrorElement, endErrorElement) {
|
|
const startValid = validateDateInput(startElement, startErrorElement);
|
|
const endValid = validateDateInput(endElement, endErrorElement);
|
|
|
|
if (!startValid || !endValid) {
|
|
return false;
|
|
}
|
|
|
|
const startDate = parseUserDate(startElement.value);
|
|
const endDate = parseUserDate(endElement.value);
|
|
|
|
if (startDate && endDate) {
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
|
|
if (start >= end) {
|
|
endErrorElement.textContent = 'End date must be after start date';
|
|
endErrorElement.style.display = 'block';
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Hybrid Date Input Functions
|
|
function setupHybridDateInput(inputId) {
|
|
const formattedInput = document.getElementById(inputId);
|
|
const nativeInput = document.getElementById(inputId + '-native');
|
|
|
|
if (!formattedInput || !nativeInput) return;
|
|
|
|
// Sync from native input to formatted input
|
|
nativeInput.addEventListener('change', function() {
|
|
if (this.value) {
|
|
formattedInput.value = formatDateForInput(this.value);
|
|
// Trigger change event on formatted input
|
|
formattedInput.dispatchEvent(new Event('change'));
|
|
}
|
|
});
|
|
|
|
// Sync from formatted input to native input
|
|
formattedInput.addEventListener('change', function() {
|
|
const isoDate = parseUserDate(this.value);
|
|
if (isoDate) {
|
|
nativeInput.value = isoDate;
|
|
} else {
|
|
nativeInput.value = '';
|
|
}
|
|
});
|
|
|
|
// Clear both inputs when formatted input is cleared
|
|
formattedInput.addEventListener('input', function() {
|
|
if (this.value === '') {
|
|
nativeInput.value = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
function openCalendarPicker(inputId) {
|
|
const nativeInput = document.getElementById(inputId + '-native');
|
|
if (nativeInput) {
|
|
// Try multiple methods to open the date picker
|
|
nativeInput.focus();
|
|
|
|
// For modern browsers
|
|
if (nativeInput.showPicker) {
|
|
try {
|
|
nativeInput.showPicker();
|
|
} catch (e) {
|
|
// Fallback to click if showPicker fails
|
|
nativeInput.click();
|
|
}
|
|
} else {
|
|
// Fallback for older browsers
|
|
nativeInput.click();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sprint Management Controller
|
|
class SprintManager {
|
|
constructor() {
|
|
this.sprints = [];
|
|
this.currentView = 'active';
|
|
this.currentSprint = null;
|
|
}
|
|
|
|
async init() {
|
|
this.setupEventListeners();
|
|
await this.loadSprints();
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// View switcher
|
|
document.querySelectorAll('.view-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
this.switchView(e.target.dataset.view);
|
|
});
|
|
});
|
|
|
|
// Actions
|
|
document.getElementById('add-sprint-btn').addEventListener('click', () => {
|
|
this.openSprintModal();
|
|
});
|
|
|
|
document.getElementById('refresh-sprints').addEventListener('click', () => {
|
|
this.loadSprints();
|
|
});
|
|
|
|
// Date validation
|
|
document.getElementById('sprint-start-date').addEventListener('blur', () => {
|
|
const startInput = document.getElementById('sprint-start-date');
|
|
const endInput = document.getElementById('sprint-end-date');
|
|
const startError = document.getElementById('sprint-start-date-error');
|
|
const endError = document.getElementById('sprint-end-date-error');
|
|
validateDateRange(startInput, endInput, startError, endError);
|
|
});
|
|
|
|
document.getElementById('sprint-end-date').addEventListener('blur', () => {
|
|
const startInput = document.getElementById('sprint-start-date');
|
|
const endInput = document.getElementById('sprint-end-date');
|
|
const startError = document.getElementById('sprint-start-date-error');
|
|
const endError = document.getElementById('sprint-end-date-error');
|
|
validateDateRange(startInput, endInput, startError, endError);
|
|
});
|
|
|
|
// Setup hybrid date inputs
|
|
setupHybridDateInput('sprint-start-date');
|
|
setupHybridDateInput('sprint-end-date');
|
|
|
|
// Modal close handlers
|
|
document.querySelectorAll('.close').forEach(closeBtn => {
|
|
closeBtn.addEventListener('click', (e) => {
|
|
e.target.closest('.modal').style.display = 'none';
|
|
});
|
|
});
|
|
|
|
// Click outside modal to close
|
|
window.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('modal')) {
|
|
e.target.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
switchView(view) {
|
|
// Update button states
|
|
document.querySelectorAll('.view-btn').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
});
|
|
document.querySelector(`[data-view="${view}"]`).classList.add('active');
|
|
|
|
this.currentView = view;
|
|
this.renderSprints();
|
|
}
|
|
|
|
async loadSprints() {
|
|
document.getElementById('loading-indicator').style.display = 'flex';
|
|
document.getElementById('error-message').style.display = 'none';
|
|
|
|
try {
|
|
const response = await fetch('/api/sprints');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
this.sprints = data.sprints;
|
|
this.renderSprints();
|
|
this.updateStatistics();
|
|
} else {
|
|
throw new Error(data.message || 'Failed to load sprints');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading sprints:', error);
|
|
document.getElementById('error-message').style.display = 'block';
|
|
} finally {
|
|
document.getElementById('loading-indicator').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
renderSprints() {
|
|
const grid = document.getElementById('sprint-grid');
|
|
grid.innerHTML = '';
|
|
|
|
const filteredSprints = this.getFilteredSprints();
|
|
|
|
if (filteredSprints.length === 0) {
|
|
grid.innerHTML = '<div class="empty-state">No sprints found for the selected view.</div>';
|
|
return;
|
|
}
|
|
|
|
filteredSprints.forEach(sprint => {
|
|
const sprintCard = this.createSprintCard(sprint);
|
|
grid.appendChild(sprintCard);
|
|
});
|
|
}
|
|
|
|
getFilteredSprints() {
|
|
return this.sprints.filter(sprint => {
|
|
switch (this.currentView) {
|
|
case 'active':
|
|
return sprint.status === 'ACTIVE';
|
|
case 'planning':
|
|
return sprint.status === 'PLANNING';
|
|
case 'completed':
|
|
return sprint.status === 'COMPLETED';
|
|
case 'all':
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
createSprintCard(sprint) {
|
|
const card = document.createElement('div');
|
|
card.className = 'management-card sprint-card';
|
|
card.addEventListener('click', () => this.openSprintModal(sprint));
|
|
|
|
const startDate = new Date(sprint.start_date);
|
|
const endDate = new Date(sprint.end_date);
|
|
const today = new Date();
|
|
|
|
// Calculate progress
|
|
let progressPercentage = 0;
|
|
if (today >= startDate && today <= endDate) {
|
|
const totalDays = (endDate - startDate) / (1000 * 60 * 60 * 24);
|
|
const elapsedDays = (today - startDate) / (1000 * 60 * 60 * 24);
|
|
progressPercentage = Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100));
|
|
} else if (today > endDate) {
|
|
progressPercentage = 100;
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="sprint-card-header">
|
|
<h3 class="sprint-name">${sprint.name}</h3>
|
|
<span class="sprint-status ${sprint.status}">${sprint.status}</span>
|
|
</div>
|
|
|
|
<div class="sprint-dates">
|
|
📅 ${formatUserDate(sprint.start_date)} - ${formatUserDate(sprint.end_date)}
|
|
${sprint.days_remaining > 0 ? `(${sprint.days_remaining} days left)` : ''}
|
|
</div>
|
|
|
|
<div class="sprint-progress">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: ${progressPercentage}%"></div>
|
|
</div>
|
|
<div class="progress-text">${Math.round(progressPercentage)}% complete</div>
|
|
</div>
|
|
|
|
<div class="sprint-metrics">
|
|
<div class="metric">
|
|
<div class="metric-number">${sprint.task_summary.total}</div>
|
|
<div class="metric-label">Total</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-number">${sprint.task_summary.completed}</div>
|
|
<div class="metric-label">Done</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-number">${sprint.task_summary.in_progress}</div>
|
|
<div class="metric-label">Active</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-number">${sprint.task_summary.not_started}</div>
|
|
<div class="metric-label">Todo</div>
|
|
</div>
|
|
</div>
|
|
|
|
${sprint.goal ? `<div class="sprint-goal">"${sprint.goal}"</div>` : ''}
|
|
`;
|
|
|
|
return card;
|
|
}
|
|
|
|
updateStatistics() {
|
|
const totalSprints = this.sprints.length;
|
|
const activeSprints = this.sprints.filter(s => s.status === 'ACTIVE').length;
|
|
const completedSprints = this.sprints.filter(s => s.status === 'COMPLETED').length;
|
|
const totalTasks = this.sprints.reduce((sum, s) => sum + s.task_summary.total, 0);
|
|
|
|
document.getElementById('total-sprints').textContent = totalSprints;
|
|
document.getElementById('active-sprints').textContent = activeSprints;
|
|
document.getElementById('completed-sprints').textContent = completedSprints;
|
|
document.getElementById('total-tasks').textContent = totalTasks;
|
|
}
|
|
|
|
openSprintModal(sprint = null) {
|
|
this.currentSprint = sprint;
|
|
const modal = document.getElementById('sprint-modal');
|
|
|
|
if (sprint) {
|
|
document.getElementById('modal-title').textContent = 'Edit Sprint';
|
|
document.getElementById('sprint-id').value = sprint.id;
|
|
document.getElementById('sprint-name').value = sprint.name;
|
|
document.getElementById('sprint-description').value = sprint.description || '';
|
|
document.getElementById('sprint-status').value = sprint.status;
|
|
document.getElementById('sprint-project').value = sprint.project_id || '';
|
|
document.getElementById('sprint-capacity').value = sprint.capacity_hours || '';
|
|
document.getElementById('sprint-start-date').value = formatDateForInput(sprint.start_date);
|
|
document.getElementById('sprint-end-date').value = formatDateForInput(sprint.end_date);
|
|
document.getElementById('sprint-goal').value = sprint.goal || '';
|
|
document.getElementById('delete-sprint-btn').style.display = 'inline-block';
|
|
} else {
|
|
document.getElementById('modal-title').textContent = 'Create New Sprint';
|
|
document.getElementById('sprint-form').reset();
|
|
document.getElementById('sprint-id').value = '';
|
|
|
|
// Set default dates (next 2 weeks)
|
|
const today = new Date();
|
|
const twoWeeksLater = new Date(today.getTime() + 14 * 24 * 60 * 60 * 1000);
|
|
document.getElementById('sprint-start-date').value = formatDateForInput(today.toISOString().split('T')[0]);
|
|
document.getElementById('sprint-end-date').value = formatDateForInput(twoWeeksLater.toISOString().split('T')[0]);
|
|
|
|
document.getElementById('delete-sprint-btn').style.display = 'none';
|
|
}
|
|
|
|
modal.style.display = 'block';
|
|
}
|
|
|
|
async saveSprint() {
|
|
// Validate date inputs before saving
|
|
const startInput = document.getElementById('sprint-start-date');
|
|
const endInput = document.getElementById('sprint-end-date');
|
|
const startError = document.getElementById('sprint-start-date-error');
|
|
const endError = document.getElementById('sprint-end-date-error');
|
|
|
|
if (!validateDateRange(startInput, endInput, startError, endError)) {
|
|
if (startError.style.display !== 'none') {
|
|
startInput.focus();
|
|
} else {
|
|
endInput.focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const sprintData = {
|
|
name: document.getElementById('sprint-name').value,
|
|
description: document.getElementById('sprint-description').value,
|
|
status: document.getElementById('sprint-status').value,
|
|
project_id: document.getElementById('sprint-project').value || null,
|
|
capacity_hours: document.getElementById('sprint-capacity').value || null,
|
|
start_date: parseUserDate(document.getElementById('sprint-start-date').value),
|
|
end_date: parseUserDate(document.getElementById('sprint-end-date').value),
|
|
goal: document.getElementById('sprint-goal').value || null
|
|
};
|
|
|
|
const sprintId = document.getElementById('sprint-id').value;
|
|
const isEdit = sprintId !== '';
|
|
|
|
try {
|
|
const response = await fetch(`/api/sprints${isEdit ? `/${sprintId}` : ''}`, {
|
|
method: isEdit ? 'PUT' : 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(sprintData)
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
closeSprintModal();
|
|
await this.loadSprints();
|
|
} else {
|
|
throw new Error(data.message || 'Failed to save sprint');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving sprint:', error);
|
|
alert('Failed to save sprint: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async deleteSprint() {
|
|
if (!this.currentSprint) return;
|
|
|
|
if (confirm(`Are you sure you want to delete sprint "${this.currentSprint.name}"? This will also remove the sprint assignment from all tasks.`)) {
|
|
try {
|
|
const response = await fetch(`/api/sprints/${this.currentSprint.id}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
closeSprintModal();
|
|
await this.loadSprints();
|
|
} else {
|
|
throw new Error(data.message || 'Failed to delete sprint');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting sprint:', error);
|
|
alert('Failed to delete sprint: ' + error.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Global functions
|
|
let sprintManager;
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
sprintManager = new SprintManager();
|
|
sprintManager.init();
|
|
});
|
|
|
|
function closeSprintModal() {
|
|
document.getElementById('sprint-modal').style.display = 'none';
|
|
sprintManager.currentSprint = null;
|
|
}
|
|
|
|
function saveSprint() {
|
|
sprintManager.saveSprint();
|
|
}
|
|
|
|
function deleteSprint() {
|
|
sprintManager.deleteSprint();
|
|
}
|
|
</script>
|
|
{% endblock %} |