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

@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento, Tag, Appuntamento
class AggiornamentoInline(admin.TabularInline):
@@ -31,9 +31,9 @@ class DocumentoObiettivoInline(admin.TabularInline):
@admin.register(Conversazione)
class ConversazioneAdmin(admin.ModelAdmin):
list_display = ('titolo', 'data', 'registrato_da')
list_filter = ('data', 'registrato_da')
list_filter = ('data', 'registrato_da', 'tags')
search_fields = ('titolo', 'contenuto')
filter_horizontal = ('partecipanti',)
filter_horizontal = ('partecipanti', 'tags')
readonly_fields = ('data',)
inlines = [CommentoConversazioneInline, DocumentoInline]
@@ -68,3 +68,23 @@ class DocumentoAdmin(admin.ModelAdmin):
search_fields = ('titolo', 'descrizione')
readonly_fields = ('data_caricamento',)
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ('nome', 'colore')
search_fields = ('nome',)
class AppuntamentoPartecipantiInline(admin.TabularInline):
model = Appuntamento.partecipanti.through
extra = 0
@admin.register(Appuntamento)
class AppuntamentoAdmin(admin.ModelAdmin):
list_display = ('titolo', 'data_ora', 'luogo', 'creato_da', 'conversazione')
list_filter = ('data_ora', 'creato_da')
search_fields = ('titolo', 'note', 'luogo')
filter_horizontal = ('partecipanti',)
readonly_fields = ('data_creazione',)

View File

@@ -1,7 +1,7 @@
from django import forms
from django.contrib.auth.models import User
from django.utils import timezone
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento, Tag, Appuntamento
class ConversazioneForm(forms.ModelForm):
@@ -20,10 +20,16 @@ class ConversazioneForm(forms.ModelForm):
required=False,
label='Partecipanti',
)
tags = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label='Tag progetto',
)
class Meta:
model = Conversazione
fields = ['titolo', 'data', 'contenuto', 'partecipanti']
fields = ['titolo', 'data', 'contenuto', 'partecipanti', 'tags']
widgets = {
'titolo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Titolo della conversazione'}),
'contenuto': forms.Textarea(attrs={'class': 'form-control', 'rows': 6, 'placeholder': 'Descrivi cosa è stato discusso...'}),
@@ -94,3 +100,29 @@ class DocumentoForm(forms.ModelForm):
'conversazione': 'Collegato a conversazione (opzionale)',
'obiettivo': 'Collegato a obiettivo (opzionale)',
}
class AppuntamentoForm(forms.ModelForm):
data_ora = forms.DateTimeField(
label='Data e ora',
widget=forms.DateTimeInput(
attrs={'class': 'form-control', 'type': 'datetime-local'},
format='%Y-%m-%dT%H:%M',
),
input_formats=['%Y-%m-%dT%H:%M'],
)
partecipanti = forms.ModelMultipleChoiceField(
queryset=User.objects.filter(is_active=True).order_by('first_name', 'username'),
widget=forms.CheckboxSelectMultiple,
required=False,
label='Partecipanti',
)
class Meta:
model = Appuntamento
fields = ['titolo', 'data_ora', 'luogo', 'note', 'partecipanti']
widgets = {
'titolo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Titolo appuntamento/riunione'}),
'luogo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Luogo (opzionale)'}),
'note': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Note (opzionale)'}),
}

View File

@@ -0,0 +1,59 @@
# Generated by Django 5.2.12 on 2026-04-07 14:27
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('diario', '0005_commento_conversazione'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Tag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('nome', models.CharField(max_length=50, unique=True)),
('colore', models.CharField(default='#4361ee', max_length=7)),
],
options={
'verbose_name': 'Tag',
'verbose_name_plural': 'Tag',
'ordering': ['nome'],
},
),
migrations.AlterField(
model_name='conversazione',
name='data',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.CreateModel(
name='Appuntamento',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('titolo', models.CharField(max_length=200)),
('data_ora', models.DateTimeField()),
('luogo', models.CharField(blank=True, max_length=200)),
('note', models.TextField(blank=True)),
('data_creazione', models.DateTimeField(auto_now_add=True)),
('conversazione', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appuntamenti', to='diario.conversazione')),
('creato_da', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appuntamenti_creati', to=settings.AUTH_USER_MODEL)),
('partecipanti', models.ManyToManyField(blank=True, related_name='appuntamenti', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Appuntamento',
'verbose_name_plural': 'Appuntamenti',
'ordering': ['data_ora'],
},
),
migrations.AddField(
model_name='conversazione',
name='tags',
field=models.ManyToManyField(blank=True, related_name='conversazioni', to='diario.tag'),
),
]

View File

@@ -11,14 +11,28 @@ def validate_file_size(value):
raise ValidationError('Il file non può superare i 10 MB.')
class Tag(models.Model):
nome = models.CharField(max_length=50, unique=True)
colore = models.CharField(max_length=7, default='#4361ee') # colore hex
class Meta:
ordering = ['nome']
verbose_name = 'Tag'
verbose_name_plural = 'Tag'
def __str__(self):
return self.nome
class Conversazione(models.Model):
titolo = models.CharField(max_length=200)
data = models.DateTimeField()
data = models.DateTimeField(default=timezone.now)
partecipanti = models.ManyToManyField(User, related_name='conversazioni_partecipate', blank=True)
contenuto = models.TextField()
registrato_da = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, related_name='conversazioni_registrate'
)
tags = models.ManyToManyField(Tag, blank=True, related_name='conversazioni')
class Meta:
ordering = ['-data']
@@ -137,3 +151,30 @@ class Documento(models.Model):
import os
return os.path.basename(self.file.name)
class Appuntamento(models.Model):
titolo = models.CharField(max_length=200)
data_ora = models.DateTimeField()
luogo = models.CharField(max_length=200, blank=True)
note = models.TextField(blank=True)
conversazione = models.ForeignKey(
Conversazione, on_delete=models.SET_NULL, null=True, blank=True, related_name='appuntamenti'
)
partecipanti = models.ManyToManyField(User, blank=True, related_name='appuntamenti')
creato_da = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, related_name='appuntamenti_creati'
)
data_creazione = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['data_ora']
verbose_name = 'Appuntamento'
verbose_name_plural = 'Appuntamenti'
def __str__(self):
return f"{self.titolo} ({self.data_ora.strftime('%d/%m/%Y %H:%M')})"
@property
def is_passato(self):
return self.data_ora < timezone.now()

View File

@@ -1,4 +1,9 @@
import re
from django import template
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.html import escape
register = template.Library()
@@ -10,3 +15,23 @@ def abs_val(value):
return abs(int(value))
except (ValueError, TypeError):
return value
@register.filter(needs_autoescape=True)
def render_mentions(text, autoescape=True):
"""Converte @username in link al profilo della persona."""
if autoescape:
text = escape(text)
def replace_mention(match):
username = match.group(1)
try:
user = User.objects.get(username=username, is_active=True)
url = reverse('persona_dettaglio', args=[user.pk])
nome = user.get_full_name() or user.username
return f'<a href="{url}" class="mention-link">@{escape(nome)}</a>'
except User.DoesNotExist:
return match.group(0)
result = re.sub(r'@(\w[\w.]+)', replace_mention, text)
return mark_safe(result)

View File

@@ -34,10 +34,20 @@ urlpatterns = [
path('documenti/<int:pk>/', views.documento_dettaglio, name='documento_dettaglio'),
path('documenti/<int:pk>/elimina/', views.documento_elimina, name='documento_elimina'),
# Appuntamenti
path('appuntamenti/', views.appuntamenti_lista, name='appuntamenti_lista'),
path('appuntamenti/nuovo/', views.appuntamento_nuovo, name='appuntamento_nuovo'),
path('appuntamenti/<int:pk>/', views.appuntamento_dettaglio, name='appuntamento_dettaglio'),
path('appuntamenti/<int:pk>/modifica/', views.appuntamento_modifica, name='appuntamento_modifica'),
path('appuntamenti/<int:pk>/elimina/', views.appuntamento_elimina, name='appuntamento_elimina'),
# Ricerca
path('ricerca/', views.ricerca, name='ricerca'),
# Persone
path('persone/', views.persone_lista, name='persone_lista'),
path('persone/<int:pk>/', views.persona_dettaglio, name='persona_dettaglio'),
# API
path('api/utenti/', views.api_utenti, name='api_utenti'),
]

View File

@@ -9,8 +9,8 @@ from django.utils import timezone
from itertools import chain
from datetime import timedelta
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
from .forms import ConversazioneForm, ObiettivoForm, AggiornamentoObiettivoForm, CommentoConversazioneForm, DocumentoForm
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento, Tag, Appuntamento
from .forms import ConversazioneForm, ObiettivoForm, AggiornamentoObiettivoForm, CommentoConversazioneForm, DocumentoForm, AppuntamentoForm
def _can_edit(user, obj):
@@ -51,11 +51,17 @@ def dashboard(request):
data_scadenza__lt=oggi,
).exclude(stato='completato').order_by('-data_scadenza')[:5]
# Prossimi appuntamenti
prossimi_appuntamenti = Appuntamento.objects.filter(
data_ora__gte=timezone.now(),
).select_related('creato_da', 'conversazione').order_by('data_ora')[:5]
return render(request, 'diario/dashboard.html', {
'eventi': eventi,
'obiettivi_aperti': obiettivi_aperti,
'scadenze_prossime': scadenze_prossime,
'scaduti': scaduti,
'prossimi_appuntamenti': prossimi_appuntamenti,
'oggi': oggi,
})
@@ -66,7 +72,7 @@ def dashboard(request):
def agenda(request):
oggi = timezone.now().date()
# Prossimi eventi: scadenze obiettivi, conversazioni programmate nel futuro
# Prossimi eventi: scadenze obiettivi, conversazioni programmate nel futuro, appuntamenti
scadenze_future = Obiettivo.objects.filter(
data_scadenza__gte=oggi,
).exclude(stato='completato').order_by('data_scadenza')
@@ -75,11 +81,16 @@ def agenda(request):
data__date__gte=oggi,
).select_related('registrato_da').order_by('data')
appuntamenti_futuri = Appuntamento.objects.filter(
data_ora__date__gte=oggi,
).select_related('creato_da', 'conversazione').order_by('data_ora')
# Eventi futuri unificati
eventi_futuri = sorted(
chain(
[{'tipo': 'scadenza', 'data': o.data_scadenza, 'obj': o} for o in scadenze_future],
[{'tipo': 'conversazione', 'data': c.data.date(), 'obj': c} for c in conversazioni_future],
[{'tipo': 'appuntamento', 'data': a.data_ora.date(), 'obj': a} for a in appuntamenti_futuri],
),
key=lambda x: x['data'],
)
@@ -96,10 +107,16 @@ def agenda(request):
data_scadenza__lt=oggi,
).order_by('-data_scadenza')
appuntamenti_passati = Appuntamento.objects.filter(
data_ora__date__gte=data_inizio,
data_ora__date__lt=oggi,
).select_related('creato_da', 'conversazione').order_by('-data_ora')
eventi_passati = sorted(
chain(
[{'tipo': 'scadenza', 'data': o.data_scadenza, 'obj': o, 'scaduto': o.stato != 'completato'} for o in scadenze_passate],
[{'tipo': 'conversazione', 'data': c.data.date(), 'obj': c, 'scaduto': False} for c in conversazioni_passate],
[{'tipo': 'appuntamento', 'data': a.data_ora.date(), 'obj': a, 'scaduto': False} for a in appuntamenti_passati],
),
key=lambda x: x['data'],
reverse=True,
@@ -141,6 +158,7 @@ def conversazione_dettaglio(request, pk):
conv = get_object_or_404(Conversazione, pk=pk)
commenti = conv.commenti.select_related('autore').order_by('-data')
documenti = conv.documenti.select_related('caricato_da').all()
appuntamenti = conv.appuntamenti.select_related('creato_da').all()
if request.method == 'POST':
comment_form = CommentoConversazioneForm(request.POST)
@@ -159,6 +177,7 @@ def conversazione_dettaglio(request, pk):
'commenti': commenti,
'comment_form': comment_form,
'documenti': documenti,
'appuntamenti': appuntamenti,
'can_edit': _can_edit(request.user, conv),
})
@@ -515,3 +534,114 @@ def persona_dettaglio(request, pk):
'documenti': documenti,
})
# ── Appuntamenti ───────────────────────────────────────────────────────────────
@login_required
def appuntamenti_lista(request):
oggi = timezone.now()
futuri = Appuntamento.objects.filter(data_ora__gte=oggi).select_related('creato_da', 'conversazione').order_by('data_ora')
passati = Appuntamento.objects.filter(data_ora__lt=oggi).select_related('creato_da', 'conversazione').order_by('-data_ora')[:30]
return render(request, 'diario/appuntamenti/lista.html', {
'futuri': futuri,
'passati': passati,
})
@login_required
def appuntamento_nuovo(request):
conversazione_pk = request.GET.get('conversazione')
initial = {}
if conversazione_pk:
conv = get_object_or_404(Conversazione, pk=conversazione_pk)
initial['titolo'] = f"Riunione: {conv.titolo}"
initial['partecipanti'] = conv.partecipanti.all()
if request.method == 'POST':
form = AppuntamentoForm(request.POST)
if form.is_valid():
app = form.save(commit=False)
app.creato_da = request.user
if conversazione_pk:
app.conversazione_id = conversazione_pk
app.save()
form.save_m2m()
messages.success(request, 'Appuntamento creato.')
if conversazione_pk:
return redirect('conversazione_dettaglio', pk=conversazione_pk)
return redirect('appuntamento_dettaglio', pk=app.pk)
else:
form = AppuntamentoForm(initial=initial)
return render(request, 'diario/appuntamenti/form.html', {
'form': form,
'titolo_pagina': 'Nuovo appuntamento',
'conversazione_pk': conversazione_pk,
})
@login_required
def appuntamento_dettaglio(request, pk):
app = get_object_or_404(Appuntamento.objects.select_related('creato_da', 'conversazione'), pk=pk)
return render(request, 'diario/appuntamenti/dettaglio.html', {
'app': app,
'can_edit': _can_edit(request.user, app),
})
@login_required
def appuntamento_modifica(request, pk):
app = get_object_or_404(Appuntamento, pk=pk)
if not _can_edit(request.user, app):
return HttpResponseForbidden('Non hai i permessi per modificare questo appuntamento.')
if request.method == 'POST':
form = AppuntamentoForm(request.POST, instance=app)
if form.is_valid():
form.save()
messages.success(request, 'Appuntamento aggiornato.')
return redirect('appuntamento_dettaglio', pk=app.pk)
else:
form = AppuntamentoForm(instance=app)
return render(request, 'diario/appuntamenti/form.html', {
'form': form,
'titolo_pagina': 'Modifica appuntamento',
'app': app,
})
@login_required
def appuntamento_elimina(request, pk):
app = get_object_or_404(Appuntamento, pk=pk)
if not _can_edit(request.user, app):
return HttpResponseForbidden('Non hai i permessi per eliminare questo appuntamento.')
if request.method == 'POST':
app.delete()
messages.success(request, 'Appuntamento eliminato.')
return redirect('appuntamenti_lista')
return render(request, 'diario/conferma_elimina.html', {
'oggetto': app,
'tipo': 'appuntamento',
'cancel_url': 'appuntamento_dettaglio',
})
# ── API: utenti per @menzioni ──────────────────────────────────────────────────
@login_required
def api_utenti(request):
"""Restituisce lista utenti per autocomplete @menzioni."""
q = request.GET.get('q', '').strip()
utenti = User.objects.filter(is_active=True)
if q:
utenti = utenti.filter(
Q(username__icontains=q) | Q(first_name__icontains=q) | Q(last_name__icontains=q)
)
data = [
{
'username': u.username,
'nome': u.get_full_name() or u.username,
'pk': u.pk,
}
for u in utenti.order_by('first_name', 'username')[:15]
]
return JsonResponse(data, safe=False)

View File

@@ -327,3 +327,74 @@ textarea.form-control:focus, input.form-control:focus { border-color: var(--acce
.card:hover .dropdown .btn-icon {
opacity: 1;
}
/* ── @Mentions ── */
.mention-link {
color: var(--accent);
font-weight: 600;
text-decoration: none;
background: var(--accent-soft);
padding: .1em .35em;
border-radius: 4px;
}
.mention-link:hover {
text-decoration: underline;
color: var(--accent-hover);
}
.mention-dropdown {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 4px;
background: #fff;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,.15);
z-index: 1050;
max-height: 220px;
overflow-y: auto;
display: none;
}
.mention-dropdown.show { display: block; }
.mention-item {
padding: .5rem .75rem;
cursor: pointer;
display: flex;
align-items: center;
gap: .5rem;
font-size: .85rem;
transition: background .1s;
}
.mention-item:hover,
.mention-item.active {
background: var(--accent-soft);
}
.mention-item small {
margin-left: auto;
}
/* ── Tag pills ── */
.tag-pill {
font-size: .68rem;
font-weight: 600;
padding: .2em .55em;
border-radius: 20px;
display: inline-flex;
align-items: center;
gap: .25rem;
border: 1.5px solid;
}
.tag-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
/* ── Appuntamento ── */
.agenda-icon-appuntamento {
background: #fef3c7; color: #d97706;
}
.appuntamento-card {
border-left: 3px solid #d97706 !important;
}

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

View File

@@ -12,6 +12,9 @@
<small class="text-muted">Panoramica di scadenze e appuntamenti</small>
</div>
<div class="d-flex gap-2">
<a href="{% url 'appuntamento_nuovo' %}" class="btn btn-sm btn-outline-warning">
<i class="bi bi-plus-lg me-1"></i>Appuntamento
</a>
<a href="{% url 'obiettivo_nuovo' %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-plus-lg me-1"></i>Obiettivo
</a>
@@ -42,6 +45,10 @@
<div class="agenda-icon agenda-icon-scadenza">
<i class="bi bi-bullseye"></i>
</div>
{% elif ev.tipo == 'appuntamento' %}
<div class="agenda-icon agenda-icon-appuntamento">
<i class="bi bi-calendar-check"></i>
</div>
{% else %}
<div class="agenda-icon agenda-icon-conv">
<i class="bi bi-chat-quote"></i>
@@ -69,6 +76,14 @@
{% endif %}
{% endwith %}
</div>
{% elif ev.tipo == 'appuntamento' %}
<a href="{% url 'appuntamento_dettaglio' ev.obj.pk %}" class="text-decoration-none text-dark fw-semibold d-block">
<i class="bi bi-calendar-check me-1 text-warning"></i>{{ ev.obj.titolo }}
</a>
<div class="d-flex align-items-center gap-2 mt-1">
<small class="text-muted"><i class="bi bi-clock me-1"></i>{{ ev.obj.data_ora|date:"H:i" }}</small>
{% if ev.obj.luogo %}<small class="text-muted"><i class="bi bi-geo-alt me-1"></i>{{ ev.obj.luogo }}</small>{% endif %}
</div>
{% else %}
<a href="{% url 'conversazione_dettaglio' ev.obj.pk %}" class="text-decoration-none text-dark fw-semibold d-block">
{{ ev.obj.titolo }}
@@ -126,6 +141,10 @@
<div class="agenda-icon {% if ev.scaduto %}agenda-icon-danger{% else %}agenda-icon-scadenza{% endif %}">
<i class="bi {% if ev.scaduto %}bi-exclamation-triangle{% else %}bi-bullseye{% endif %}"></i>
</div>
{% elif ev.tipo == 'appuntamento' %}
<div class="agenda-icon agenda-icon-appuntamento" style="opacity:.6;">
<i class="bi bi-calendar-check"></i>
</div>
{% else %}
<div class="agenda-icon agenda-icon-conv" style="opacity:.6;">
<i class="bi bi-chat-quote"></i>
@@ -142,6 +161,11 @@
<div class="d-flex align-items-center gap-2 mt-1">
<span class="badge-stato stato-{{ ev.obj.stato }}">{{ ev.obj.get_stato_display }}</span>
</div>
{% elif ev.tipo == 'appuntamento' %}
<a href="{% url 'appuntamento_dettaglio' ev.obj.pk %}" class="text-decoration-none text-muted fw-semibold d-block">
<i class="bi bi-calendar-check me-1"></i>{{ ev.obj.titolo }}
</a>
<small class="text-muted">{{ ev.obj.data_ora|date:"H:i" }}{% if ev.obj.luogo %} · {{ ev.obj.luogo }}{% endif %}</small>
{% else %}
<a href="{% url 'conversazione_dettaglio' ev.obj.pk %}" class="text-decoration-none text-muted fw-semibold d-block">
{{ ev.obj.titolo }}

View File

@@ -0,0 +1,80 @@
{% extends "diario/base.html" %}
{% block title %}{{ app.titolo }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex align-items-center mb-4 gap-3 fade-in">
<a href="{% url 'appuntamenti_lista' %}" class="btn btn-icon btn-outline-secondary">
<i class="bi bi-arrow-left" style="font-size:.85rem;"></i>
</a>
<h5 class="mb-0 fw-bold flex-grow-1">
<i class="bi bi-calendar-check me-2 text-warning"></i>{{ app.titolo }}
</h5>
{% if can_edit %}
<a href="{% url 'appuntamento_modifica' app.pk %}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Modifica
</a>
<a href="{% url 'appuntamento_elimina' app.pk %}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash me-1"></i>Elimina
</a>
{% endif %}
</div>
<div class="card p-4 mb-4 fade-in appuntamento-card">
<div class="row g-3 mb-3">
<div class="col-sm-6">
<small class="text-muted fw-semibold d-block mb-1">Data e ora</small>
<div class="fw-semibold">
<i class="bi bi-clock me-1 text-warning"></i>
{{ app.data_ora|date:"d/m/Y \a\l\l\e H:i" }}
</div>
{% if app.is_passato %}
<span class="countdown countdown-urgent mt-1">Passato</span>
{% endif %}
</div>
{% if app.luogo %}
<div class="col-sm-6">
<small class="text-muted fw-semibold d-block mb-1">Luogo</small>
<div><i class="bi bi-geo-alt me-1 text-muted"></i>{{ app.luogo }}</div>
</div>
{% endif %}
</div>
{% if app.note %}
<hr class="soft">
<small class="text-muted fw-semibold d-block mb-2">Note</small>
<div style="white-space:pre-wrap;line-height:1.8;font-size:.93rem;">{{ app.note }}</div>
{% endif %}
{% if app.conversazione %}
<hr class="soft">
<small class="text-muted fw-semibold d-block mb-2">Conversazione collegata</small>
<a href="{% url 'conversazione_dettaglio' app.conversazione.pk %}" class="text-decoration-none">
<i class="bi bi-chat-quote me-1"></i>{{ app.conversazione.titolo }}
</a>
{% endif %}
{% if app.partecipanti.all %}
<hr class="soft mt-3 mb-3">
<small class="text-muted fw-semibold d-block mb-2">Partecipanti</small>
<div class="d-flex flex-wrap gap-2">
{% for p in app.partecipanti.all %}
<div class="d-flex align-items-center gap-1 px-2 py-1 rounded-pill" style="background:#f0f2f5; font-size:.8rem;">
<span class="avatar" style="width:20px;height:20px;font-size:.55rem;">{{ p.username|slice:":2"|upper }}</span>
{{ p.get_full_name|default:p.username }}
</div>
{% endfor %}
</div>
{% endif %}
<hr class="soft mt-3 mb-2">
<small class="text-muted">
Creato da {{ app.creato_da.get_full_name|default:app.creato_da.username }} il {{ app.data_creazione|date:"d/m/Y H:i" }}
</small>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "diario/base.html" %}
{% block title %}{{ titolo_pagina }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex align-items-center mb-4">
<a href="{% url 'appuntamenti_lista' %}" class="btn btn-sm btn-outline-secondary me-3">
<i class="bi bi-arrow-left"></i>
</a>
<h4 class="mb-0">{{ titolo_pagina }}</h4>
</div>
<div class="card p-4">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.titolo.label }}</label>
{{ form.titolo }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.data_ora.label }}</label>
{{ form.data_ora }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.luogo.label }}</label>
{{ form.luogo }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.note.label }}</label>
{{ form.note }}
</div>
<div class="mb-4">
<label class="form-label fw-semibold">{{ form.partecipanti.label }}</label>
<div class="row row-cols-2 row-cols-sm-3 g-2 mt-1">
{% for checkbox in form.partecipanti %}
<div class="col">
<div class="form-check">
{{ checkbox.tag }}
<label class="form-check-label" for="{{ checkbox.id_for_label }}">
{{ checkbox.choice_label }}
</label>
</div>
</div>
{% endfor %}
</div>
</div>
{% if conversazione_pk %}
<input type="hidden" name="conversazione" value="{{ conversazione_pk }}">
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Salva</button>
{% if app %}
<a href="{% url 'appuntamento_dettaglio' app.pk %}" class="btn btn-outline-secondary">Annulla</a>
{% else %}
<a href="{% url 'appuntamenti_lista' %}" class="btn btn-outline-secondary">Annulla</a>
{% endif %}
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends "diario/base.html" %}
{% block title %}Appuntamenti{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 fade-in">
<p class="section-title mb-0">Appuntamenti e Riunioni</p>
<a href="{% url 'appuntamento_nuovo' %}" class="btn btn-primary btn-sm px-3">
<i class="bi bi-plus-lg me-1"></i>Nuovo appuntamento
</a>
</div>
{% if futuri %}
<p class="section-title fade-in"><i class="bi bi-calendar-event me-1"></i>Prossimi</p>
{% for app in futuri %}
<div class="card mb-2 p-3 fade-in appuntamento-card">
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="flex-grow-1">
<a href="{% url 'appuntamento_dettaglio' app.pk %}" class="text-decoration-none text-dark fw-semibold d-block mb-1">
<i class="bi bi-calendar-check me-1 text-warning"></i>{{ app.titolo }}
</a>
<div class="d-flex align-items-center gap-3 flex-wrap">
<small class="text-muted"><i class="bi bi-clock me-1"></i>{{ app.data_ora|date:"d/m/Y H:i" }}</small>
{% if app.luogo %}
<small class="text-muted"><i class="bi bi-geo-alt me-1"></i>{{ app.luogo }}</small>
{% endif %}
{% if app.conversazione %}
<small class="text-muted">
<i class="bi bi-link-45deg me-1"></i>
<a href="{% url 'conversazione_dettaglio' app.conversazione.pk %}" class="text-muted">{{ app.conversazione.titolo|truncatewords:5 }}</a>
</small>
{% endif %}
{% if app.partecipanti.count > 0 %}
<small class="text-muted"><i class="bi bi-people me-1"></i>{{ app.partecipanti.count }}</small>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% if passati %}
<p class="section-title mt-4 fade-in"><i class="bi bi-clock-history me-1"></i>Passati</p>
{% for app in passati %}
<div class="card mb-2 p-3 fade-in" style="opacity:.7;">
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="flex-grow-1">
<a href="{% url 'appuntamento_dettaglio' app.pk %}" class="text-decoration-none text-muted fw-semibold d-block mb-1">
<i class="bi bi-calendar-x me-1"></i>{{ app.titolo }}
</a>
<div class="d-flex align-items-center gap-3 flex-wrap">
<small class="text-muted"><i class="bi bi-clock me-1"></i>{{ app.data_ora|date:"d/m/Y H:i" }}</small>
{% if app.luogo %}
<small class="text-muted"><i class="bi bi-geo-alt me-1"></i>{{ app.luogo }}</small>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% if not futuri and not passati %}
<div class="empty-state">
<i class="bi bi-calendar-plus"></i>
<p>Nessun appuntamento registrato.</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -41,6 +41,11 @@
<i class="bi bi-calendar-week me-1"></i>Agenda
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'appuntamenti_lista' %}">
<i class="bi bi-calendar-check me-1"></i>Appuntamenti
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'documenti_lista' %}">
<i class="bi bi-file-earmark-pdf me-1"></i>Documenti

View File

@@ -1,4 +1,5 @@
{% extends "diario/base.html" %}
{% load custom_filters %}
{% block title %}{{ conv.titolo }}{% endblock %}
{% block content %}
@@ -31,6 +32,17 @@
<div style="white-space:pre-wrap;line-height:1.8;font-size:.93rem;">{{ conv.contenuto }}</div>
{% if conv.tags.all %}
<div class="d-flex flex-wrap gap-2 mt-3">
{% for tag in conv.tags.all %}
<span class="tag-pill" style="border-color:{{ tag.colore }};color:{{ tag.colore }};">
<span class="tag-dot" style="background:{{ tag.colore }};"></span>
{{ tag.nome }}
</span>
{% endfor %}
</div>
{% endif %}
{% if conv.partecipanti.all %}
<hr class="soft mt-4 mb-3">
<div>
@@ -47,8 +59,35 @@
{% endif %}
</div>
<!-- Documenti allegati -->
<!-- Appuntamenti collegati -->
<div class="d-flex justify-content-between align-items-center mb-3 fade-in">
<p class="section-title mb-0"><i class="bi bi-calendar-check me-1"></i>Appuntamenti ({{ appuntamenti|length }})</p>
<a href="{% url 'appuntamento_nuovo' %}?conversazione={{ conv.pk }}" class="btn btn-sm btn-outline-warning">
<i class="bi bi-plus-lg me-1"></i>Crea riunione
</a>
</div>
{% for app in appuntamenti %}
<div class="card mb-2 p-3 fade-in appuntamento-card">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-calendar-check" style="font-size:1.3rem;color:#d97706;"></i>
<div class="flex-grow-1">
<a href="{% url 'appuntamento_dettaglio' app.pk %}" class="text-decoration-none text-dark fw-semibold small d-block">
{{ app.titolo }}
</a>
<small class="text-muted">
{{ app.data_ora|date:"d/m/Y H:i" }}
{% if app.luogo %} · {{ app.luogo }}{% endif %}
</small>
</div>
</div>
</div>
{% empty %}
<p class="text-muted small text-center py-2 fade-in">Nessun appuntamento collegato.</p>
{% endfor %}
<!-- Documenti allegati -->
<div class="d-flex justify-content-between align-items-center mb-3 mt-4 fade-in">
<p class="section-title mb-0"><i class="bi bi-paperclip me-1"></i>Allegati ({{ documenti|length }})</p>
<a href="{% url 'documento_nuovo' %}?conversazione={{ conv.pk }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-upload me-1"></i>Allega PDF
@@ -111,7 +150,7 @@
{% endif %}
</div>
</div>
<p class="mb-0 mt-2 small" style="white-space:pre-wrap;line-height:1.65;padding-left:40px;">{{ c.testo }}</p>
<p class="mb-0 mt-2 small" style="white-space:pre-wrap;line-height:1.65;padding-left:40px;">{{ c.testo|render_mentions }}</p>
</div>
{% empty %}
<p class="text-muted small text-center py-3">Nessun commento ancora. Sii il primo!</p>

View File

@@ -42,6 +42,22 @@
{% endfor %}
</div>
</div>
{% if form.tags.field.queryset.count > 0 %}
<div class="mb-4">
<label class="form-label fw-semibold">{{ form.tags.label }}</label>
<div class="d-flex flex-wrap gap-2 mt-1">
{% for checkbox in form.tags %}
<div class="form-check">
{{ checkbox.tag }}
<label class="form-check-label" for="{{ checkbox.id_for_label }}">
{{ checkbox.choice_label }}
</label>
</div>
{% endfor %}
</div>
<div class="form-text">Seleziona i tag progetto da associare.</div>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Salva</button>
{% if conv %}

View File

@@ -27,6 +27,16 @@
{% endif %}
</div>
<p class="mb-0 text-muted small mt-2">{{ conv.contenuto|truncatewords:30 }}</p>
{% if conv.tags.all %}
<div class="d-flex flex-wrap gap-1 mt-2">
{% for tag in conv.tags.all %}
<span class="tag-pill" style="border-color:{{ tag.colore }};color:{{ tag.colore }};font-size:.62rem;">
<span class="tag-dot" style="background:{{ tag.colore }};"></span>
{{ tag.nome }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
{% if user == conv.registrato_da or user.is_superuser %}
<a href="{% url 'conversazione_modifica' conv.pk %}" class="btn btn-icon btn-outline-secondary flex-shrink-0">

View File

@@ -230,6 +230,32 @@
</div>
{% endif %}
<!-- Prossimi appuntamenti -->
{% if prossimi_appuntamenti %}
<div class="card mt-4 fade-in">
<div class="card-header-accent d-flex justify-content-between align-items-center" style="background:#fef3c7; color:#d97706; border-bottom-color:#fde68a;">
<span><i class="bi bi-calendar-check me-2"></i>Prossimi appuntamenti</span>
<a href="{% url 'appuntamenti_lista' %}" class="small fw-normal" style="color:#d97706;">Tutti →</a>
</div>
<div class="p-0">
{% for app in prossimi_appuntamenti %}
<div class="d-flex align-items-center gap-3 px-3 py-2 {% if not forloop.last %}border-bottom{% endif %}">
<div class="text-center flex-shrink-0" style="width:36px;">
<div style="font-size:.85rem; font-weight:700; color:#d97706;">{{ app.data_ora|date:"d" }}</div>
<div style="font-size:.6rem; text-transform:uppercase; color:#94a3b8;">{{ app.data_ora|date:"M" }}</div>
</div>
<div class="flex-grow-1">
<a href="{% url 'appuntamento_dettaglio' app.pk %}" class="text-decoration-none text-dark fw-semibold small d-block">
{{ app.titolo|truncatewords:6 }}
</a>
<small class="text-muted">{{ app.data_ora|date:"H:i" }}{% if app.luogo %} · {{ app.luogo }}{% endif %}</small>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -162,7 +162,7 @@
{% endif %}
</div>
</div>
<p class="mb-0 mt-2 small" style="white-space:pre-wrap;line-height:1.65;padding-left:40px;">{{ agg.testo }}</p>
<p class="mb-0 mt-2 small" style="white-space:pre-wrap;line-height:1.65;padding-left:40px;">{{ agg.testo|render_mentions }}</p>
</div>
{% empty %}
<p class="text-muted small text-center py-3">Nessun aggiornamento ancora.</p>