Initial commit
Dieser Commit ist enthalten in:
469
frontend/js/proposals.js
Normale Datei
469
frontend/js/proposals.js
Normale Datei
@ -0,0 +1,469 @@
|
||||
/**
|
||||
* TASKMATE - Proposals Manager
|
||||
* ============================
|
||||
* Genehmigungen (projektbezogen)
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import { $, $$ } from './utils.js';
|
||||
import authManager from './auth.js';
|
||||
import store from './store.js';
|
||||
|
||||
class ProposalsManager {
|
||||
constructor() {
|
||||
this.proposals = [];
|
||||
this.currentSort = 'date';
|
||||
this.showArchived = false;
|
||||
this.searchQuery = '';
|
||||
this.allTasks = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('[Proposals] init() called, initialized:', this.initialized);
|
||||
|
||||
if (this.initialized) {
|
||||
await this.loadProposals();
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM Elements - erst bei init() laden
|
||||
this.proposalsView = $('#view-proposals');
|
||||
this.proposalsList = $('#proposals-list');
|
||||
this.proposalsEmpty = $('#proposals-empty');
|
||||
this.sortSelect = $('#proposals-sort');
|
||||
this.newProposalBtn = $('#btn-new-proposal');
|
||||
this.archiveBtn = $('#btn-show-proposals-archive');
|
||||
this.proposalsTitle = $('#proposals-title');
|
||||
|
||||
console.log('[Proposals] newProposalBtn found:', !!this.newProposalBtn);
|
||||
|
||||
// Modal Elements
|
||||
this.proposalModal = $('#proposal-modal');
|
||||
this.proposalForm = $('#proposal-form');
|
||||
this.proposalTitle = $('#proposal-title');
|
||||
this.proposalDescription = $('#proposal-description');
|
||||
this.proposalTask = $('#proposal-task');
|
||||
|
||||
this.bindEvents();
|
||||
this.initialized = true;
|
||||
console.log('[Proposals] Initialization complete');
|
||||
|
||||
await this.loadProposals();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
console.log('[Proposals] bindEvents() called');
|
||||
|
||||
// Sort Change
|
||||
this.sortSelect?.addEventListener('change', () => {
|
||||
this.currentSort = this.sortSelect.value;
|
||||
this.loadProposals();
|
||||
});
|
||||
|
||||
// Archive Toggle Button
|
||||
this.archiveBtn?.addEventListener('click', () => {
|
||||
this.showArchived = !this.showArchived;
|
||||
this.updateArchiveButton();
|
||||
this.loadProposals();
|
||||
});
|
||||
|
||||
// New Proposal Button
|
||||
if (this.newProposalBtn) {
|
||||
console.log('[Proposals] Binding click event to newProposalBtn');
|
||||
this.newProposalBtn.addEventListener('click', () => {
|
||||
console.log('[Proposals] Button clicked!');
|
||||
this.openNewProposalModal();
|
||||
});
|
||||
} else {
|
||||
console.error('[Proposals] newProposalBtn not found!');
|
||||
}
|
||||
|
||||
// Proposal Form Submit
|
||||
this.proposalForm?.addEventListener('submit', (e) => this.handleProposalSubmit(e));
|
||||
|
||||
// Modal close buttons
|
||||
this.proposalModal?.querySelectorAll('[data-close-modal]').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.closeModal());
|
||||
});
|
||||
}
|
||||
|
||||
updateArchiveButton() {
|
||||
if (this.archiveBtn) {
|
||||
this.archiveBtn.textContent = this.showArchived ? 'Aktive anzeigen' : 'Archiv anzeigen';
|
||||
}
|
||||
if (this.proposalsTitle) {
|
||||
this.proposalsTitle.textContent = this.showArchived ? 'Archiv' : 'Genehmigungen';
|
||||
}
|
||||
}
|
||||
|
||||
resetToActiveView() {
|
||||
this.showArchived = false;
|
||||
this.searchQuery = '';
|
||||
this.updateArchiveButton();
|
||||
this.loadProposals();
|
||||
}
|
||||
|
||||
setSearchQuery(query) {
|
||||
this.searchQuery = query.toLowerCase().trim();
|
||||
this.render();
|
||||
}
|
||||
|
||||
getFilteredProposals() {
|
||||
if (!this.searchQuery) {
|
||||
return this.proposals;
|
||||
}
|
||||
return this.proposals.filter(proposal => {
|
||||
const titleMatch = proposal.title?.toLowerCase().includes(this.searchQuery);
|
||||
const descriptionMatch = proposal.description?.toLowerCase().includes(this.searchQuery);
|
||||
return titleMatch || descriptionMatch;
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentProjectId() {
|
||||
// Direkt die currentProjectId aus dem Store holen
|
||||
return store.get('currentProjectId') || null;
|
||||
}
|
||||
|
||||
async loadProposals() {
|
||||
try {
|
||||
const projectId = this.getCurrentProjectId();
|
||||
this.proposals = await api.getProposals(this.currentSort, this.showArchived, projectId);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Error loading proposals:', error);
|
||||
this.showToast('Fehler beim Laden der Genehmigungen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadTasks() {
|
||||
try {
|
||||
this.allTasks = await api.getAllTasks();
|
||||
this.populateTaskDropdown();
|
||||
} catch (error) {
|
||||
console.error('Error loading tasks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
populateTaskDropdown() {
|
||||
if (!this.proposalTask) return;
|
||||
|
||||
const currentProjectId = this.getCurrentProjectId();
|
||||
|
||||
// Reset dropdown
|
||||
this.proposalTask.innerHTML = '<option value="">Keine Aufgabe</option>';
|
||||
|
||||
// Nur Aufgaben des aktuellen Projekts anzeigen
|
||||
const projectTasks = this.allTasks.filter(task => task.project_id === currentProjectId);
|
||||
|
||||
projectTasks.forEach(task => {
|
||||
const option = document.createElement('option');
|
||||
option.value = task.id;
|
||||
option.textContent = task.title;
|
||||
this.proposalTask.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.proposalsList) return;
|
||||
|
||||
const filteredProposals = this.getFilteredProposals();
|
||||
|
||||
if (filteredProposals.length === 0) {
|
||||
this.proposalsList.classList.add('hidden');
|
||||
this.proposalsEmpty?.classList.remove('hidden');
|
||||
// Update empty message based on search
|
||||
if (this.proposalsEmpty) {
|
||||
const h3 = this.proposalsEmpty.querySelector('h3');
|
||||
const p = this.proposalsEmpty.querySelector('p');
|
||||
if (this.searchQuery) {
|
||||
if (h3) h3.textContent = 'Keine Treffer';
|
||||
if (p) p.textContent = 'Keine Genehmigungen entsprechen der Suche.';
|
||||
} else {
|
||||
if (h3) h3.textContent = 'Keine Genehmigungen vorhanden';
|
||||
if (p) p.textContent = 'Erstellen Sie die erste Genehmigung!';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.proposalsEmpty?.classList.add('hidden');
|
||||
this.proposalsList.classList.remove('hidden');
|
||||
|
||||
const currentUserId = authManager.getUser()?.id;
|
||||
const canApprove = authManager.hasPermission('genehmigung');
|
||||
|
||||
this.proposalsList.innerHTML = filteredProposals.map(proposal =>
|
||||
this.renderProposalCard(proposal, currentUserId, canApprove)
|
||||
).join('');
|
||||
|
||||
// Bind event listeners
|
||||
this.bindProposalEvents();
|
||||
}
|
||||
|
||||
renderProposalCard(proposal, currentUserId, canApprove) {
|
||||
const isOwn = proposal.created_by === currentUserId;
|
||||
const initial = (proposal.created_by_name || 'U').charAt(0).toUpperCase();
|
||||
const dateStr = this.formatDate(proposal.created_at);
|
||||
const isArchived = proposal.archived === 1;
|
||||
|
||||
return `
|
||||
<div class="proposal-card ${proposal.approved ? 'approved' : ''} ${isArchived ? 'archived' : ''}" data-proposal-id="${proposal.id}">
|
||||
<div class="proposal-header">
|
||||
<h3 class="proposal-title">${this.escapeHtml(proposal.title)}</h3>
|
||||
<div class="proposal-badges">
|
||||
${isArchived ? `
|
||||
<span class="proposal-archived-badge">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 8v13H3V8M1 3h22v5H1zM10 12h4" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
Archiviert
|
||||
</span>
|
||||
` : ''}
|
||||
${proposal.approved ? `
|
||||
<span class="proposal-approved-badge">
|
||||
<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" stroke="currentColor" stroke-width="2" fill="none"/><path d="m22 4-10 10-3-3" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
Genehmigt
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${proposal.description ? `
|
||||
<p class="proposal-description">${this.escapeHtml(proposal.description)}</p>
|
||||
` : ''}
|
||||
${proposal.task_title ? `
|
||||
<div class="proposal-linked-task">
|
||||
<svg viewBox="0 0 24 24"><path d="M9 11l3 3L22 4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
<span>Verknüpft mit: ${this.escapeHtml(proposal.task_title)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="proposal-meta">
|
||||
<div class="proposal-author">
|
||||
<span class="proposal-author-avatar" style="background-color: ${proposal.created_by_color || '#888'}">
|
||||
${initial}
|
||||
</span>
|
||||
<span>${this.escapeHtml(proposal.created_by_name)}</span>
|
||||
<span class="proposal-date">${dateStr}</span>
|
||||
</div>
|
||||
<div class="proposal-actions">
|
||||
${canApprove && !isArchived ? `
|
||||
<label class="proposal-approve ${proposal.approved ? 'approved' : ''}" title="Genehmigen">
|
||||
<input type="checkbox" data-action="approve" ${proposal.approved ? 'checked' : ''}>
|
||||
<span>Genehmigt</span>
|
||||
</label>
|
||||
` : ''}
|
||||
${canApprove ? `
|
||||
<button class="proposal-archive-btn" data-action="archive" title="${isArchived ? 'Wiederherstellen' : 'Archivieren'}">
|
||||
${isArchived ? `
|
||||
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
` : `
|
||||
<svg viewBox="0 0 24 24"><path d="M21 8v13H3V8M1 3h22v5H1zM10 12h4" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
`}
|
||||
</button>
|
||||
` : ''}
|
||||
${(isOwn || canApprove) && !isArchived ? `
|
||||
<button class="proposal-delete-btn" data-action="delete" title="Löschen">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${proposal.approved && proposal.approved_by_name ? `
|
||||
<div class="proposal-approved-by">
|
||||
Genehmigt von ${this.escapeHtml(proposal.approved_by_name)} am ${this.formatDate(proposal.approved_at)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindProposalEvents() {
|
||||
this.proposalsList?.querySelectorAll('.proposal-card').forEach(card => {
|
||||
const proposalId = parseInt(card.dataset.proposalId);
|
||||
const proposal = this.proposals.find(p => p.id === proposalId);
|
||||
|
||||
// Approve checkbox
|
||||
const approveCheckbox = card.querySelector('[data-action="approve"]');
|
||||
approveCheckbox?.addEventListener('change', (e) => this.handleApprove(proposalId, e.target.checked));
|
||||
|
||||
// Archive button
|
||||
const archiveBtn = card.querySelector('[data-action="archive"]');
|
||||
archiveBtn?.addEventListener('click', () => this.handleArchive(proposalId, proposal?.archived !== 1));
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = card.querySelector('[data-action="delete"]');
|
||||
deleteBtn?.addEventListener('click', () => this.handleDelete(proposalId));
|
||||
});
|
||||
}
|
||||
|
||||
async handleApprove(proposalId, approved) {
|
||||
try {
|
||||
await api.approveProposal(proposalId, approved);
|
||||
await this.loadProposals();
|
||||
this.showToast(approved ? 'Genehmigung erteilt' : 'Genehmigung zurückgezogen', 'success');
|
||||
// Board aktualisieren, damit die Genehmigung auf der Task-Karte aktualisiert wird
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Genehmigen', 'error');
|
||||
// Revert checkbox
|
||||
await this.loadProposals();
|
||||
}
|
||||
}
|
||||
|
||||
async handleArchive(proposalId, archive) {
|
||||
try {
|
||||
await api.archiveProposal(proposalId, archive);
|
||||
await this.loadProposals();
|
||||
this.showToast(archive ? 'Genehmigung archiviert' : 'Genehmigung wiederhergestellt', 'success');
|
||||
// Board aktualisieren
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Archivieren', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete(proposalId) {
|
||||
const proposal = this.proposals.find(p => p.id === proposalId);
|
||||
if (!proposal) return;
|
||||
|
||||
const confirmDelete = confirm(`Genehmigung "${proposal.title}" wirklich löschen?`);
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
await api.deleteProposal(proposalId);
|
||||
this.showToast('Genehmigung gelöscht', 'success');
|
||||
await this.loadProposals();
|
||||
// Board aktualisieren
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Löschen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async openNewProposalModal() {
|
||||
console.log('[Proposals] Opening new proposal modal');
|
||||
this.proposalForm?.reset();
|
||||
await this.loadTasks();
|
||||
this.openModal();
|
||||
this.proposalTitle?.focus();
|
||||
}
|
||||
|
||||
async handleProposalSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const title = this.proposalTitle.value.trim();
|
||||
const description = this.proposalDescription.value.trim();
|
||||
const taskId = this.proposalTask?.value ? parseInt(this.proposalTask.value) : null;
|
||||
const projectId = this.getCurrentProjectId();
|
||||
|
||||
if (!title) {
|
||||
this.showToast('Bitte einen Titel eingeben', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
this.showToast('Kein Projekt ausgewählt', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.createProposal({ title, description, taskId, projectId });
|
||||
this.showToast('Genehmigung erstellt', 'success');
|
||||
this.closeModal();
|
||||
await this.loadProposals();
|
||||
// Board aktualisieren, damit die Genehmigung auf der Task-Karte erscheint
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Erstellen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
openModal() {
|
||||
if (this.proposalModal) {
|
||||
this.proposalModal.classList.remove('hidden');
|
||||
this.proposalModal.classList.add('visible');
|
||||
}
|
||||
const overlay = $('#modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
store.openModal('proposal-modal');
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
if (this.proposalModal) {
|
||||
this.proposalModal.classList.remove('visible');
|
||||
this.proposalModal.classList.add('hidden');
|
||||
}
|
||||
// Only hide overlay if no other modals are open
|
||||
const openModals = store.get('openModals').filter(id => id !== 'proposal-modal');
|
||||
if (openModals.length === 0) {
|
||||
const overlay = $('#modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
store.closeModal('proposal-modal');
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type }
|
||||
}));
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
show() {
|
||||
this.proposalsView?.classList.remove('hidden');
|
||||
this.proposalsView?.classList.add('active');
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.proposalsView?.classList.add('hidden');
|
||||
this.proposalsView?.classList.remove('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrollt zu einer Genehmigung und hebt sie hervor
|
||||
*/
|
||||
scrollToAndHighlight(proposalId) {
|
||||
const card = this.proposalsList?.querySelector(`[data-proposal-id="${proposalId}"]`);
|
||||
if (!card) {
|
||||
console.warn('[Proposals] Proposal card not found:', proposalId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll to the card
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// Add highlight class for animation
|
||||
card.classList.add('highlight-pulse');
|
||||
|
||||
// Remove highlight class after animation
|
||||
setTimeout(() => {
|
||||
card.classList.remove('highlight-pulse');
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const proposalsManager = new ProposalsManager();
|
||||
|
||||
export { proposalsManager };
|
||||
export default proposalsManager;
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren