Tag progetto, @menzioni, appuntamenti da conversazioni

- Modello Tag con nome e colore, M2M su Conversazione
- Modello Appuntamento con luogo, note, partecipanti, link a Conversazione
- @menzioni nei commenti e aggiornamenti: @username → link al profilo
- Autocomplete JS per @menzioni nelle textarea
- Auto-data conversazioni (default=now)
- CRUD completo appuntamenti con permessi autore
- Appuntamenti in agenda, dashboard, dettaglio conversazione
- Crea riunione direttamente da una conversazione (pre-compila titolo e partecipanti)
- Admin: Tag, Appuntamento registrati
This commit is contained in:
automationkriz
2026-04-07 14:28:47 +00:00
parent 006bb24215
commit 09f51b1227
19 changed files with 828 additions and 12 deletions

View File

@@ -148,6 +148,102 @@ function initApp() {
convertMessagesToToasts();
initSliders();
initLiveSearch();
initMentions();
}
// ── @Mentions autocomplete ──
function initMentions() {
document.querySelectorAll('textarea.form-control').forEach(textarea => {
if (textarea._mentionInit) return;
textarea._mentionInit = true;
let dropdown = textarea.parentElement.querySelector('.mention-dropdown');
if (!dropdown) {
dropdown = document.createElement('div');
dropdown.className = 'mention-dropdown';
textarea.parentElement.style.position = 'relative';
textarea.parentElement.appendChild(dropdown);
}
let mentionStart = -1;
let timer = null;
textarea.addEventListener('input', () => {
const pos = textarea.selectionStart;
const text = textarea.value.substring(0, pos);
const atMatch = text.match(/@(\w[\w.]*)$/);
if (!atMatch) {
dropdown.classList.remove('show');
mentionStart = -1;
return;
}
mentionStart = pos - atMatch[0].length;
const query = atMatch[1];
clearTimeout(timer);
timer = setTimeout(() => {
fetch('/api/utenti/?q=' + encodeURIComponent(query))
.then(r => r.json())
.then(users => {
if (!users.length) {
dropdown.classList.remove('show');
return;
}
dropdown.innerHTML = users.map(u =>
`<div class="mention-item" data-username="${u.username}" data-nome="${u.nome}">
<span class="avatar" style="width:22px;height:22px;font-size:.6rem;">${u.username.substring(0,2).toUpperCase()}</span>
<span>${u.nome}</span>
<small class="text-muted">@${u.username}</small>
</div>`
).join('');
dropdown.classList.add('show');
});
}, 200);
});
dropdown.addEventListener('click', (e) => {
const item = e.target.closest('.mention-item');
if (!item) return;
const username = item.dataset.username;
const before = textarea.value.substring(0, mentionStart);
const after = textarea.value.substring(textarea.selectionStart);
textarea.value = before + '@' + username + ' ' + after;
textarea.focus();
const newPos = mentionStart + username.length + 2;
textarea.setSelectionRange(newPos, newPos);
dropdown.classList.remove('show');
});
textarea.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.remove('show'), 200);
});
textarea.addEventListener('keydown', (e) => {
if (!dropdown.classList.contains('show')) return;
const items = dropdown.querySelectorAll('.mention-item');
const active = dropdown.querySelector('.mention-item.active');
let idx = Array.from(items).indexOf(active);
if (e.key === 'ArrowDown') {
e.preventDefault();
if (active) active.classList.remove('active');
idx = (idx + 1) % items.length;
items[idx].classList.add('active');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (active) active.classList.remove('active');
idx = idx <= 0 ? items.length - 1 : idx - 1;
items[idx].classList.add('active');
} else if (e.key === 'Enter' && active) {
e.preventDefault();
active.click();
} else if (e.key === 'Escape') {
dropdown.classList.remove('show');
}
});
});
}
// Run on initial page load