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:
@@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
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):
|
class AggiornamentoInline(admin.TabularInline):
|
||||||
@@ -31,9 +31,9 @@ class DocumentoObiettivoInline(admin.TabularInline):
|
|||||||
@admin.register(Conversazione)
|
@admin.register(Conversazione)
|
||||||
class ConversazioneAdmin(admin.ModelAdmin):
|
class ConversazioneAdmin(admin.ModelAdmin):
|
||||||
list_display = ('titolo', 'data', 'registrato_da')
|
list_display = ('titolo', 'data', 'registrato_da')
|
||||||
list_filter = ('data', 'registrato_da')
|
list_filter = ('data', 'registrato_da', 'tags')
|
||||||
search_fields = ('titolo', 'contenuto')
|
search_fields = ('titolo', 'contenuto')
|
||||||
filter_horizontal = ('partecipanti',)
|
filter_horizontal = ('partecipanti', 'tags')
|
||||||
readonly_fields = ('data',)
|
readonly_fields = ('data',)
|
||||||
inlines = [CommentoConversazioneInline, DocumentoInline]
|
inlines = [CommentoConversazioneInline, DocumentoInline]
|
||||||
|
|
||||||
@@ -68,3 +68,23 @@ class DocumentoAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('titolo', 'descrizione')
|
search_fields = ('titolo', 'descrizione')
|
||||||
readonly_fields = ('data_caricamento',)
|
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',)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
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):
|
class ConversazioneForm(forms.ModelForm):
|
||||||
@@ -20,10 +20,16 @@ class ConversazioneForm(forms.ModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label='Partecipanti',
|
label='Partecipanti',
|
||||||
)
|
)
|
||||||
|
tags = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=Tag.objects.all(),
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=False,
|
||||||
|
label='Tag progetto',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Conversazione
|
model = Conversazione
|
||||||
fields = ['titolo', 'data', 'contenuto', 'partecipanti']
|
fields = ['titolo', 'data', 'contenuto', 'partecipanti', 'tags']
|
||||||
widgets = {
|
widgets = {
|
||||||
'titolo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Titolo della conversazione'}),
|
'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...'}),
|
'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)',
|
'conversazione': 'Collegato a conversazione (opzionale)',
|
||||||
'obiettivo': 'Collegato a obiettivo (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)'}),
|
||||||
|
}
|
||||||
|
|||||||
59
diario/migrations/0006_tag_appuntamento.py
Normal file
59
diario/migrations/0006_tag_appuntamento.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -11,14 +11,28 @@ def validate_file_size(value):
|
|||||||
raise ValidationError('Il file non può superare i 10 MB.')
|
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):
|
class Conversazione(models.Model):
|
||||||
titolo = models.CharField(max_length=200)
|
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)
|
partecipanti = models.ManyToManyField(User, related_name='conversazioni_partecipate', blank=True)
|
||||||
contenuto = models.TextField()
|
contenuto = models.TextField()
|
||||||
registrato_da = models.ForeignKey(
|
registrato_da = models.ForeignKey(
|
||||||
User, on_delete=models.SET_NULL, null=True, related_name='conversazioni_registrate'
|
User, on_delete=models.SET_NULL, null=True, related_name='conversazioni_registrate'
|
||||||
)
|
)
|
||||||
|
tags = models.ManyToManyField(Tag, blank=True, related_name='conversazioni')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-data']
|
ordering = ['-data']
|
||||||
@@ -137,3 +151,30 @@ class Documento(models.Model):
|
|||||||
import os
|
import os
|
||||||
return os.path.basename(self.file.name)
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import re
|
||||||
from django import template
|
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()
|
register = template.Library()
|
||||||
|
|
||||||
@@ -10,3 +15,23 @@ def abs_val(value):
|
|||||||
return abs(int(value))
|
return abs(int(value))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return value
|
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)
|
||||||
|
|||||||
@@ -34,10 +34,20 @@ urlpatterns = [
|
|||||||
path('documenti/<int:pk>/', views.documento_dettaglio, name='documento_dettaglio'),
|
path('documenti/<int:pk>/', views.documento_dettaglio, name='documento_dettaglio'),
|
||||||
path('documenti/<int:pk>/elimina/', views.documento_elimina, name='documento_elimina'),
|
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
|
# Ricerca
|
||||||
path('ricerca/', views.ricerca, name='ricerca'),
|
path('ricerca/', views.ricerca, name='ricerca'),
|
||||||
|
|
||||||
# Persone
|
# Persone
|
||||||
path('persone/', views.persone_lista, name='persone_lista'),
|
path('persone/', views.persone_lista, name='persone_lista'),
|
||||||
path('persone/<int:pk>/', views.persona_dettaglio, name='persona_dettaglio'),
|
path('persone/<int:pk>/', views.persona_dettaglio, name='persona_dettaglio'),
|
||||||
|
|
||||||
|
# API
|
||||||
|
path('api/utenti/', views.api_utenti, name='api_utenti'),
|
||||||
]
|
]
|
||||||
|
|||||||
136
diario/views.py
136
diario/views.py
@@ -9,8 +9,8 @@ from django.utils import timezone
|
|||||||
from itertools import chain
|
from itertools import chain
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
|
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento, Tag, Appuntamento
|
||||||
from .forms import ConversazioneForm, ObiettivoForm, AggiornamentoObiettivoForm, CommentoConversazioneForm, DocumentoForm
|
from .forms import ConversazioneForm, ObiettivoForm, AggiornamentoObiettivoForm, CommentoConversazioneForm, DocumentoForm, AppuntamentoForm
|
||||||
|
|
||||||
|
|
||||||
def _can_edit(user, obj):
|
def _can_edit(user, obj):
|
||||||
@@ -51,11 +51,17 @@ def dashboard(request):
|
|||||||
data_scadenza__lt=oggi,
|
data_scadenza__lt=oggi,
|
||||||
).exclude(stato='completato').order_by('-data_scadenza')[:5]
|
).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', {
|
return render(request, 'diario/dashboard.html', {
|
||||||
'eventi': eventi,
|
'eventi': eventi,
|
||||||
'obiettivi_aperti': obiettivi_aperti,
|
'obiettivi_aperti': obiettivi_aperti,
|
||||||
'scadenze_prossime': scadenze_prossime,
|
'scadenze_prossime': scadenze_prossime,
|
||||||
'scaduti': scaduti,
|
'scaduti': scaduti,
|
||||||
|
'prossimi_appuntamenti': prossimi_appuntamenti,
|
||||||
'oggi': oggi,
|
'oggi': oggi,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ def dashboard(request):
|
|||||||
def agenda(request):
|
def agenda(request):
|
||||||
oggi = timezone.now().date()
|
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(
|
scadenze_future = Obiettivo.objects.filter(
|
||||||
data_scadenza__gte=oggi,
|
data_scadenza__gte=oggi,
|
||||||
).exclude(stato='completato').order_by('data_scadenza')
|
).exclude(stato='completato').order_by('data_scadenza')
|
||||||
@@ -75,11 +81,16 @@ def agenda(request):
|
|||||||
data__date__gte=oggi,
|
data__date__gte=oggi,
|
||||||
).select_related('registrato_da').order_by('data')
|
).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 unificati
|
||||||
eventi_futuri = sorted(
|
eventi_futuri = sorted(
|
||||||
chain(
|
chain(
|
||||||
[{'tipo': 'scadenza', 'data': o.data_scadenza, 'obj': o} for o in scadenze_future],
|
[{'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': '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'],
|
key=lambda x: x['data'],
|
||||||
)
|
)
|
||||||
@@ -96,10 +107,16 @@ def agenda(request):
|
|||||||
data_scadenza__lt=oggi,
|
data_scadenza__lt=oggi,
|
||||||
).order_by('-data_scadenza')
|
).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(
|
eventi_passati = sorted(
|
||||||
chain(
|
chain(
|
||||||
[{'tipo': 'scadenza', 'data': o.data_scadenza, 'obj': o, 'scaduto': o.stato != 'completato'} for o in scadenze_passate],
|
[{'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': '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'],
|
key=lambda x: x['data'],
|
||||||
reverse=True,
|
reverse=True,
|
||||||
@@ -141,6 +158,7 @@ def conversazione_dettaglio(request, pk):
|
|||||||
conv = get_object_or_404(Conversazione, pk=pk)
|
conv = get_object_or_404(Conversazione, pk=pk)
|
||||||
commenti = conv.commenti.select_related('autore').order_by('-data')
|
commenti = conv.commenti.select_related('autore').order_by('-data')
|
||||||
documenti = conv.documenti.select_related('caricato_da').all()
|
documenti = conv.documenti.select_related('caricato_da').all()
|
||||||
|
appuntamenti = conv.appuntamenti.select_related('creato_da').all()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
comment_form = CommentoConversazioneForm(request.POST)
|
comment_form = CommentoConversazioneForm(request.POST)
|
||||||
@@ -159,6 +177,7 @@ def conversazione_dettaglio(request, pk):
|
|||||||
'commenti': commenti,
|
'commenti': commenti,
|
||||||
'comment_form': comment_form,
|
'comment_form': comment_form,
|
||||||
'documenti': documenti,
|
'documenti': documenti,
|
||||||
|
'appuntamenti': appuntamenti,
|
||||||
'can_edit': _can_edit(request.user, conv),
|
'can_edit': _can_edit(request.user, conv),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -515,3 +534,114 @@ def persona_dettaglio(request, pk):
|
|||||||
'documenti': documenti,
|
'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)
|
||||||
|
|
||||||
|
|||||||
@@ -327,3 +327,74 @@ textarea.form-control:focus, input.form-control:focus { border-color: var(--acce
|
|||||||
.card:hover .dropdown .btn-icon {
|
.card:hover .dropdown .btn-icon {
|
||||||
opacity: 1;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,6 +148,102 @@ function initApp() {
|
|||||||
convertMessagesToToasts();
|
convertMessagesToToasts();
|
||||||
initSliders();
|
initSliders();
|
||||||
initLiveSearch();
|
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
|
// Run on initial page load
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
<small class="text-muted">Panoramica di scadenze e appuntamenti</small>
|
<small class="text-muted">Panoramica di scadenze e appuntamenti</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<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">
|
<a href="{% url 'obiettivo_nuovo' %}" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-plus-lg me-1"></i>Obiettivo
|
<i class="bi bi-plus-lg me-1"></i>Obiettivo
|
||||||
</a>
|
</a>
|
||||||
@@ -42,6 +45,10 @@
|
|||||||
<div class="agenda-icon agenda-icon-scadenza">
|
<div class="agenda-icon agenda-icon-scadenza">
|
||||||
<i class="bi bi-bullseye"></i>
|
<i class="bi bi-bullseye"></i>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif ev.tipo == 'appuntamento' %}
|
||||||
|
<div class="agenda-icon agenda-icon-appuntamento">
|
||||||
|
<i class="bi bi-calendar-check"></i>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="agenda-icon agenda-icon-conv">
|
<div class="agenda-icon agenda-icon-conv">
|
||||||
<i class="bi bi-chat-quote"></i>
|
<i class="bi bi-chat-quote"></i>
|
||||||
@@ -69,6 +76,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</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 %}
|
{% else %}
|
||||||
<a href="{% url 'conversazione_dettaglio' ev.obj.pk %}" class="text-decoration-none text-dark fw-semibold d-block">
|
<a href="{% url 'conversazione_dettaglio' ev.obj.pk %}" class="text-decoration-none text-dark fw-semibold d-block">
|
||||||
{{ ev.obj.titolo }}
|
{{ ev.obj.titolo }}
|
||||||
@@ -126,6 +141,10 @@
|
|||||||
<div class="agenda-icon {% if ev.scaduto %}agenda-icon-danger{% else %}agenda-icon-scadenza{% endif %}">
|
<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>
|
<i class="bi {% if ev.scaduto %}bi-exclamation-triangle{% else %}bi-bullseye{% endif %}"></i>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif ev.tipo == 'appuntamento' %}
|
||||||
|
<div class="agenda-icon agenda-icon-appuntamento" style="opacity:.6;">
|
||||||
|
<i class="bi bi-calendar-check"></i>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="agenda-icon agenda-icon-conv" style="opacity:.6;">
|
<div class="agenda-icon agenda-icon-conv" style="opacity:.6;">
|
||||||
<i class="bi bi-chat-quote"></i>
|
<i class="bi bi-chat-quote"></i>
|
||||||
@@ -142,6 +161,11 @@
|
|||||||
<div class="d-flex align-items-center gap-2 mt-1">
|
<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>
|
<span class="badge-stato stato-{{ ev.obj.stato }}">{{ ev.obj.get_stato_display }}</span>
|
||||||
</div>
|
</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 %}
|
{% else %}
|
||||||
<a href="{% url 'conversazione_dettaglio' ev.obj.pk %}" class="text-decoration-none text-muted fw-semibold d-block">
|
<a href="{% url 'conversazione_dettaglio' ev.obj.pk %}" class="text-decoration-none text-muted fw-semibold d-block">
|
||||||
{{ ev.obj.titolo }}
|
{{ ev.obj.titolo }}
|
||||||
|
|||||||
80
templates/diario/appuntamenti/dettaglio.html
Normal file
80
templates/diario/appuntamenti/dettaglio.html
Normal 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 %}
|
||||||
63
templates/diario/appuntamenti/form.html
Normal file
63
templates/diario/appuntamenti/form.html
Normal 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 %}
|
||||||
69
templates/diario/appuntamenti/lista.html
Normal file
69
templates/diario/appuntamenti/lista.html
Normal 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 %}
|
||||||
@@ -41,6 +41,11 @@
|
|||||||
<i class="bi bi-calendar-week me-1"></i>Agenda
|
<i class="bi bi-calendar-week me-1"></i>Agenda
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'documenti_lista' %}">
|
<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
|
<i class="bi bi-file-earmark-pdf me-1"></i>Documenti
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "diario/base.html" %}
|
{% extends "diario/base.html" %}
|
||||||
|
{% load custom_filters %}
|
||||||
{% block title %}{{ conv.titolo }}{% endblock %}
|
{% block title %}{{ conv.titolo }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -31,6 +32,17 @@
|
|||||||
|
|
||||||
<div style="white-space:pre-wrap;line-height:1.8;font-size:.93rem;">{{ conv.contenuto }}</div>
|
<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 %}
|
{% if conv.partecipanti.all %}
|
||||||
<hr class="soft mt-4 mb-3">
|
<hr class="soft mt-4 mb-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -47,8 +59,35 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Documenti allegati -->
|
<!-- Appuntamenti collegati -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 fade-in">
|
<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>
|
<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">
|
<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
|
<i class="bi bi-upload me-1"></i>Allega PDF
|
||||||
@@ -111,7 +150,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<p class="text-muted small text-center py-3">Nessun commento ancora. Sii il primo!</p>
|
<p class="text-muted small text-center py-3">Nessun commento ancora. Sii il primo!</p>
|
||||||
|
|||||||
@@ -42,6 +42,22 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">Salva</button>
|
<button type="submit" class="btn btn-primary">Salva</button>
|
||||||
{% if conv %}
|
{% if conv %}
|
||||||
|
|||||||
@@ -27,6 +27,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-0 text-muted small mt-2">{{ conv.contenuto|truncatewords:30 }}</p>
|
<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>
|
</div>
|
||||||
{% if user == conv.registrato_da or user.is_superuser %}
|
{% 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">
|
<a href="{% url 'conversazione_modifica' conv.pk %}" class="btn btn-icon btn-outline-secondary flex-shrink-0">
|
||||||
|
|||||||
@@ -230,6 +230,32 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<p class="text-muted small text-center py-3">Nessun aggiornamento ancora.</p>
|
<p class="text-muted small text-center py-3">Nessun aggiornamento ancora.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user