Initial commit: Diario Conversazioni Olimpic Nastri
- Django 5.2 + PostgreSQL + Gunicorn - Conversazioni, Obiettivi, Documenti PDF, Persone - Commenti e aggiornamenti con modifica/eliminazione - Agenda, ricerca live, giorni rimanenti scadenze - Bootstrap 5 + HTMX + toast notifications - Deploy: Nginx + Gunicorn + SSL
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Virtual environment
|
||||||
|
nastrivenv/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
db.sqlite3
|
||||||
|
|
||||||
|
# Media uploads
|
||||||
|
media/
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
backup.json
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Static files collected
|
||||||
|
staticfiles/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
18
UTENTI.txt
Normal file
18
UTENTI.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
OLIMPIC PROGETTO NASTRI — Credenziali utenti
|
||||||
|
============================================
|
||||||
|
|
||||||
|
Nome Username Password
|
||||||
|
--------------------------+-----------------------+----------
|
||||||
|
Stefano Longhi stefano.longhi nastri2026
|
||||||
|
Roberto Bertocchi roberto.bertocchi nastri2026
|
||||||
|
Emanuele Noè emanuele.noe nastri2026
|
||||||
|
Francesco Tripaldi francesco.tripaldi nastri2026
|
||||||
|
Francesca Russo Cirillo francesca.russo nastri2026
|
||||||
|
Maurizio Fonda maurizio.fonda nastri2026
|
||||||
|
Renzo Sorci renzo.sorci nastri2026
|
||||||
|
Sandro Marega sandro.marega nastri2026
|
||||||
|
Stefania Bertocchi stefania.bertocchi nastri2026
|
||||||
|
Marco Tonsi marco.tonsi nastri2026
|
||||||
|
|
||||||
|
URL applicazione: https://diario.olimpic.click/
|
||||||
|
URL pannello admin: https://diario.olimpic.click/admin/
|
||||||
39
diario.olimpic.click.nginx
Normal file
39
diario.olimpic.click.nginx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name diario.olimpic.click;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name diario.olimpic.click;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/diario.olimpic.click/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/diario.olimpic.click/privkey.pem;
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /home/marco/olimpic_nastri/staticfiles/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media/ {
|
||||||
|
alias /home/marco/olimpic_nastri/media/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://unix:/run/olimpic_nastri/gunicorn.sock;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log /var/log/nginx/diario_olimpic_error.log;
|
||||||
|
access_log /var/log/nginx/diario_olimpic_access.log;
|
||||||
|
}
|
||||||
0
diario/__init__.py
Normal file
0
diario/__init__.py
Normal file
70
diario/admin.py
Normal file
70
diario/admin.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
|
||||||
|
|
||||||
|
|
||||||
|
class AggiornamentoInline(admin.TabularInline):
|
||||||
|
model = AggiornamentoObiettivo
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ('data',)
|
||||||
|
|
||||||
|
|
||||||
|
class CommentoConversazioneInline(admin.TabularInline):
|
||||||
|
model = CommentoConversazione
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ('data',)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentoInline(admin.TabularInline):
|
||||||
|
model = Documento
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ('data_caricamento',)
|
||||||
|
fk_name = 'conversazione'
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentoObiettivoInline(admin.TabularInline):
|
||||||
|
model = Documento
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ('data_caricamento',)
|
||||||
|
fk_name = 'obiettivo'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Conversazione)
|
||||||
|
class ConversazioneAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('titolo', 'data', 'registrato_da')
|
||||||
|
list_filter = ('data', 'registrato_da')
|
||||||
|
search_fields = ('titolo', 'contenuto')
|
||||||
|
filter_horizontal = ('partecipanti',)
|
||||||
|
readonly_fields = ('data',)
|
||||||
|
inlines = [CommentoConversazioneInline, DocumentoInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Obiettivo)
|
||||||
|
class ObiettivoAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('titolo', 'tipo', 'stato', 'data_scadenza', 'creato_da')
|
||||||
|
list_filter = ('tipo', 'stato', 'assegnato_a')
|
||||||
|
search_fields = ('titolo', 'descrizione')
|
||||||
|
readonly_fields = ('data_creazione',)
|
||||||
|
inlines = [AggiornamentoInline, DocumentoObiettivoInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AggiornamentoObiettivo)
|
||||||
|
class AggiornamentoObiettivoAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('obiettivo', 'autore', 'data')
|
||||||
|
list_filter = ('autore', 'data')
|
||||||
|
readonly_fields = ('data',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CommentoConversazione)
|
||||||
|
class CommentoConversazioneAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('conversazione', 'autore', 'data')
|
||||||
|
list_filter = ('autore', 'data')
|
||||||
|
readonly_fields = ('data',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Documento)
|
||||||
|
class DocumentoAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('titolo', 'caricato_da', 'data_caricamento', 'conversazione', 'obiettivo')
|
||||||
|
list_filter = ('caricato_da', 'data_caricamento')
|
||||||
|
search_fields = ('titolo', 'descrizione')
|
||||||
|
readonly_fields = ('data_caricamento',)
|
||||||
|
|
||||||
6
diario/apps.py
Normal file
6
diario/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DiarioConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'diario'
|
||||||
96
diario/forms.py
Normal file
96
diario/forms.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
|
||||||
|
|
||||||
|
|
||||||
|
class ConversazioneForm(forms.ModelForm):
|
||||||
|
data = forms.DateTimeField(
|
||||||
|
label='Data e ora',
|
||||||
|
initial=timezone.now,
|
||||||
|
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 = Conversazione
|
||||||
|
fields = ['titolo', 'data', 'contenuto', 'partecipanti']
|
||||||
|
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...'}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'titolo': 'Titolo',
|
||||||
|
'contenuto': 'Contenuto',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ObiettivoForm(forms.ModelForm):
|
||||||
|
assegnato_a = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=User.objects.filter(is_active=True).order_by('first_name', 'username'),
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=False,
|
||||||
|
label='Assegnato a',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Obiettivo
|
||||||
|
fields = ['titolo', 'descrizione', 'tipo', 'assegnato_a', 'stato', 'data_scadenza']
|
||||||
|
widgets = {
|
||||||
|
'titolo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Titolo obiettivo'}),
|
||||||
|
'descrizione': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'Descrizione (opzionale)'}),
|
||||||
|
'tipo': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'stato': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'data_scadenza': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AggiornamentoObiettivoForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = AggiornamentoObiettivo
|
||||||
|
fields = ['testo']
|
||||||
|
widgets = {
|
||||||
|
'testo': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Scrivi un aggiornamento...'}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'testo': 'Aggiornamento',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CommentoConversazioneForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = CommentoConversazione
|
||||||
|
fields = ['testo']
|
||||||
|
widgets = {
|
||||||
|
'testo': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Scrivi un commento...'}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'testo': 'Commento',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentoForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Documento
|
||||||
|
fields = ['titolo', 'descrizione', 'file', 'conversazione', 'obiettivo']
|
||||||
|
widgets = {
|
||||||
|
'titolo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Titolo del documento'}),
|
||||||
|
'descrizione': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Descrizione (opzionale)'}),
|
||||||
|
'file': forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': '.pdf'}),
|
||||||
|
'conversazione': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'obiettivo': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'file': 'File PDF',
|
||||||
|
'conversazione': 'Collegato a conversazione (opzionale)',
|
||||||
|
'obiettivo': 'Collegato a obiettivo (opzionale)',
|
||||||
|
}
|
||||||
67
diario/migrations/0001_initial.py
Normal file
67
diario/migrations/0001_initial.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-01 12:43
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Conversazione',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('titolo', models.CharField(max_length=200)),
|
||||||
|
('data', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('contenuto', models.TextField()),
|
||||||
|
('partecipanti', models.ManyToManyField(blank=True, related_name='conversazioni_partecipate', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('registrato_da', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='conversazioni_registrate', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Conversazione',
|
||||||
|
'verbose_name_plural': 'Conversazioni',
|
||||||
|
'ordering': ['-data'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Obiettivo',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('titolo', models.CharField(max_length=200)),
|
||||||
|
('descrizione', models.TextField(blank=True)),
|
||||||
|
('tipo', models.CharField(choices=[('collettivo', 'Collettivo'), ('individuale', 'Individuale')], default='collettivo', max_length=20)),
|
||||||
|
('stato', models.CharField(choices=[('aperto', 'Aperto'), ('in_corso', 'In corso'), ('completato', 'Completato'), ('sospeso', 'Sospeso')], default='aperto', max_length=20)),
|
||||||
|
('data_scadenza', models.DateField(blank=True, null=True)),
|
||||||
|
('data_creazione', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('assegnato_a', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='obiettivi_assegnati', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('creato_da', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='obiettivi_creati', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Obiettivo',
|
||||||
|
'verbose_name_plural': 'Obiettivi',
|
||||||
|
'ordering': ['-data_creazione'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AggiornamentoObiettivo',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('testo', models.TextField()),
|
||||||
|
('data', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('autore', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aggiornamenti', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('obiettivo', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aggiornamenti', to='diario.obiettivo')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Aggiornamento',
|
||||||
|
'verbose_name_plural': 'Aggiornamenti',
|
||||||
|
'ordering': ['-data'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
diario/migrations/0002_obiettivo_avanzamento.py
Normal file
18
diario/migrations/0002_obiettivo_avanzamento.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-01 13:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('diario', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='obiettivo',
|
||||||
|
name='avanzamento',
|
||||||
|
field=models.PositiveSmallIntegerField(default=0),
|
||||||
|
),
|
||||||
|
]
|
||||||
29
diario/migrations/0003_alter_conversazione_data_and_more.py
Normal file
29
diario/migrations/0003_alter_conversazione_data_and_more.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-01 13:59
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('diario', '0002_obiettivo_avanzamento'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='conversazione',
|
||||||
|
name='data',
|
||||||
|
field=models.DateTimeField(),
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='obiettivo',
|
||||||
|
name='assegnato_a',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='obiettivo',
|
||||||
|
name='assegnato_a',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='obiettivi_assegnati', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
36
diario/migrations/0004_documento.py
Normal file
36
diario/migrations/0004_documento.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-04 16:45
|
||||||
|
|
||||||
|
import diario.models
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('diario', '0003_alter_conversazione_data_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Documento',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file', models.FileField(upload_to='documenti/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf']), diario.models.validate_file_size])),
|
||||||
|
('titolo', models.CharField(max_length=200)),
|
||||||
|
('descrizione', models.TextField(blank=True)),
|
||||||
|
('data_caricamento', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('caricato_da', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documenti_caricati', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('conversazione', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documenti', to='diario.conversazione')),
|
||||||
|
('obiettivo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documenti', to='diario.obiettivo')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Documento',
|
||||||
|
'verbose_name_plural': 'Documenti',
|
||||||
|
'ordering': ['-data_caricamento'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
31
diario/migrations/0005_commento_conversazione.py
Normal file
31
diario/migrations/0005_commento_conversazione.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-05 14:30
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('diario', '0004_documento'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CommentoConversazione',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('testo', models.TextField()),
|
||||||
|
('data', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('autore', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='commenti_conversazione', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('conversazione', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commenti', to='diario.conversazione')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Commento',
|
||||||
|
'verbose_name_plural': 'Commenti',
|
||||||
|
'ordering': ['-data'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
diario/migrations/__init__.py
Normal file
0
diario/migrations/__init__.py
Normal file
139
diario/models.py
Normal file
139
diario/models.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.validators import FileExtensionValidator
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file_size(value):
|
||||||
|
limit = 10 * 1024 * 1024 # 10 MB
|
||||||
|
if value.size > limit:
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
raise ValidationError('Il file non può superare i 10 MB.')
|
||||||
|
|
||||||
|
|
||||||
|
class Conversazione(models.Model):
|
||||||
|
titolo = models.CharField(max_length=200)
|
||||||
|
data = models.DateTimeField()
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-data']
|
||||||
|
verbose_name = 'Conversazione'
|
||||||
|
verbose_name_plural = 'Conversazioni'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.titolo} ({self.data.strftime('%d/%m/%Y')})"
|
||||||
|
|
||||||
|
|
||||||
|
class Obiettivo(models.Model):
|
||||||
|
TIPO_CHOICES = [
|
||||||
|
('collettivo', 'Collettivo'),
|
||||||
|
('individuale', 'Individuale'),
|
||||||
|
]
|
||||||
|
STATO_CHOICES = [
|
||||||
|
('aperto', 'Aperto'),
|
||||||
|
('in_corso', 'In corso'),
|
||||||
|
('completato', 'Completato'),
|
||||||
|
('sospeso', 'Sospeso'),
|
||||||
|
]
|
||||||
|
|
||||||
|
titolo = models.CharField(max_length=200)
|
||||||
|
descrizione = models.TextField(blank=True)
|
||||||
|
avanzamento = models.PositiveSmallIntegerField(default=0) # 0-100
|
||||||
|
tipo = models.CharField(max_length=20, choices=TIPO_CHOICES, default='collettivo')
|
||||||
|
assegnato_a = models.ManyToManyField(
|
||||||
|
User, blank=True,
|
||||||
|
related_name='obiettivi_assegnati'
|
||||||
|
)
|
||||||
|
stato = models.CharField(max_length=20, choices=STATO_CHOICES, default='aperto')
|
||||||
|
data_scadenza = models.DateField(null=True, blank=True)
|
||||||
|
creato_da = models.ForeignKey(
|
||||||
|
User, on_delete=models.SET_NULL, null=True, related_name='obiettivi_creati'
|
||||||
|
)
|
||||||
|
data_creazione = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-data_creazione']
|
||||||
|
verbose_name = 'Obiettivo'
|
||||||
|
verbose_name_plural = 'Obiettivi'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.titolo
|
||||||
|
|
||||||
|
@property
|
||||||
|
def giorni_rimanenti(self):
|
||||||
|
"""Restituisce i giorni rimanenti alla scadenza (None se non impostata)."""
|
||||||
|
if not self.data_scadenza:
|
||||||
|
return None
|
||||||
|
delta = self.data_scadenza - timezone.now().date()
|
||||||
|
return delta.days
|
||||||
|
|
||||||
|
|
||||||
|
class AggiornamentoObiettivo(models.Model):
|
||||||
|
obiettivo = models.ForeignKey(Obiettivo, on_delete=models.CASCADE, related_name='aggiornamenti')
|
||||||
|
testo = models.TextField()
|
||||||
|
autore = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='aggiornamenti')
|
||||||
|
data = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-data']
|
||||||
|
verbose_name = 'Aggiornamento'
|
||||||
|
verbose_name_plural = 'Aggiornamenti'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Aggiornamento su '{self.obiettivo}' del {self.data.strftime('%d/%m/%Y')}"
|
||||||
|
|
||||||
|
|
||||||
|
class CommentoConversazione(models.Model):
|
||||||
|
conversazione = models.ForeignKey(Conversazione, on_delete=models.CASCADE, related_name='commenti')
|
||||||
|
testo = models.TextField()
|
||||||
|
autore = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='commenti_conversazione')
|
||||||
|
data = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-data']
|
||||||
|
verbose_name = 'Commento'
|
||||||
|
verbose_name_plural = 'Commenti'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Commento su '{self.conversazione}' del {self.data.strftime('%d/%m/%Y')}"
|
||||||
|
|
||||||
|
|
||||||
|
class Documento(models.Model):
|
||||||
|
file = models.FileField(
|
||||||
|
upload_to='documenti/%Y/%m/',
|
||||||
|
validators=[
|
||||||
|
FileExtensionValidator(allowed_extensions=['pdf']),
|
||||||
|
validate_file_size,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
titolo = models.CharField(max_length=200)
|
||||||
|
descrizione = models.TextField(blank=True)
|
||||||
|
caricato_da = models.ForeignKey(
|
||||||
|
User, on_delete=models.SET_NULL, null=True, related_name='documenti_caricati'
|
||||||
|
)
|
||||||
|
data_caricamento = models.DateTimeField(auto_now_add=True)
|
||||||
|
conversazione = models.ForeignKey(
|
||||||
|
'Conversazione', on_delete=models.SET_NULL, null=True, blank=True, related_name='documenti'
|
||||||
|
)
|
||||||
|
obiettivo = models.ForeignKey(
|
||||||
|
'Obiettivo', on_delete=models.SET_NULL, null=True, blank=True, related_name='documenti'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-data_caricamento']
|
||||||
|
verbose_name = 'Documento'
|
||||||
|
verbose_name_plural = 'Documenti'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.titolo
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filename(self):
|
||||||
|
import os
|
||||||
|
return os.path.basename(self.file.name)
|
||||||
|
|
||||||
0
diario/templatetags/__init__.py
Normal file
0
diario/templatetags/__init__.py
Normal file
12
diario/templatetags/custom_filters.py
Normal file
12
diario/templatetags/custom_filters.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def abs_val(value):
|
||||||
|
"""Restituisce il valore assoluto."""
|
||||||
|
try:
|
||||||
|
return abs(int(value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return value
|
||||||
3
diario/tests.py
Normal file
3
diario/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
43
diario/urls.py
Normal file
43
diario/urls.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.dashboard, name='dashboard'),
|
||||||
|
path('agenda/', views.agenda, name='agenda'),
|
||||||
|
|
||||||
|
# Conversazioni
|
||||||
|
path('conversazioni/', views.conversazioni_lista, name='conversazioni_lista'),
|
||||||
|
path('conversazioni/nuova/', views.conversazione_nuova, name='conversazione_nuova'),
|
||||||
|
path('conversazioni/<int:pk>/', views.conversazione_dettaglio, name='conversazione_dettaglio'),
|
||||||
|
path('conversazioni/<int:pk>/modifica/', views.conversazione_modifica, name='conversazione_modifica'),
|
||||||
|
path('conversazioni/<int:pk>/elimina/', views.conversazione_elimina, name='conversazione_elimina'),
|
||||||
|
|
||||||
|
# Commenti conversazioni
|
||||||
|
path('commenti/<int:pk>/modifica/', views.commento_modifica, name='commento_modifica'),
|
||||||
|
path('commenti/<int:pk>/elimina/', views.commento_elimina, name='commento_elimina'),
|
||||||
|
|
||||||
|
# Aggiornamenti obiettivi
|
||||||
|
path('aggiornamenti/<int:pk>/modifica/', views.aggiornamento_modifica, name='aggiornamento_modifica'),
|
||||||
|
path('aggiornamenti/<int:pk>/elimina/', views.aggiornamento_elimina, name='aggiornamento_elimina'),
|
||||||
|
|
||||||
|
# Obiettivi
|
||||||
|
path('obiettivi/', views.obiettivi_lista, name='obiettivi_lista'),
|
||||||
|
path('obiettivi/nuovo/', views.obiettivo_nuovo, name='obiettivo_nuovo'),
|
||||||
|
path('obiettivi/<int:pk>/', views.obiettivo_dettaglio, name='obiettivo_dettaglio'),
|
||||||
|
path('obiettivi/<int:pk>/modifica/', views.obiettivo_modifica, name='obiettivo_modifica'),
|
||||||
|
path('obiettivi/<int:pk>/elimina/', views.obiettivo_elimina, name='obiettivo_elimina'),
|
||||||
|
path('obiettivi/<int:pk>/avanzamento/', views.obiettivo_avanzamento_ajax, name='obiettivo_avanzamento_ajax'),
|
||||||
|
|
||||||
|
# Documenti
|
||||||
|
path('documenti/', views.documenti_lista, name='documenti_lista'),
|
||||||
|
path('documenti/nuovo/', views.documento_nuovo, name='documento_nuovo'),
|
||||||
|
path('documenti/<int:pk>/', views.documento_dettaglio, name='documento_dettaglio'),
|
||||||
|
path('documenti/<int:pk>/elimina/', views.documento_elimina, name='documento_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'),
|
||||||
|
]
|
||||||
517
diario/views.py
Normal file
517
diario/views.py
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.http import JsonResponse, HttpResponseForbidden
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from django.db.models import Q, Count
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _can_edit(user, obj):
|
||||||
|
"""Autore o superuser possono modificare/eliminare."""
|
||||||
|
owner = getattr(obj, 'registrato_da', None) or getattr(obj, 'creato_da', None) or getattr(obj, 'caricato_da', None) or getattr(obj, 'autore', None)
|
||||||
|
return user == owner or user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dashboard ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def dashboard(request):
|
||||||
|
conversazioni = Conversazione.objects.select_related('registrato_da').order_by('-data')[:20]
|
||||||
|
aggiornamenti = AggiornamentoObiettivo.objects.select_related('autore', 'obiettivo').order_by('-data')[:20]
|
||||||
|
documenti_recenti = Documento.objects.select_related('caricato_da').order_by('-data_caricamento')[:10]
|
||||||
|
|
||||||
|
eventi = sorted(
|
||||||
|
chain(
|
||||||
|
[{'tipo': 'conversazione', 'data': c.data, 'obj': c} for c in conversazioni],
|
||||||
|
[{'tipo': 'aggiornamento', 'data': a.data, 'obj': a} for a in aggiornamenti],
|
||||||
|
[{'tipo': 'documento', 'data': d.data_caricamento, 'obj': d} for d in documenti_recenti],
|
||||||
|
),
|
||||||
|
key=lambda x: x['data'],
|
||||||
|
reverse=True,
|
||||||
|
)[:30]
|
||||||
|
|
||||||
|
obiettivi_aperti = Obiettivo.objects.exclude(stato='completato').order_by('-data_creazione')[:5]
|
||||||
|
|
||||||
|
# Agenda: prossime scadenze obiettivi (prossimi 30 giorni)
|
||||||
|
oggi = timezone.now().date()
|
||||||
|
scadenze_prossime = Obiettivo.objects.filter(
|
||||||
|
data_scadenza__gte=oggi,
|
||||||
|
data_scadenza__lte=oggi + timedelta(days=30),
|
||||||
|
).exclude(stato='completato').order_by('data_scadenza')[:8]
|
||||||
|
|
||||||
|
# Scadenze passate non completate
|
||||||
|
scaduti = Obiettivo.objects.filter(
|
||||||
|
data_scadenza__lt=oggi,
|
||||||
|
).exclude(stato='completato').order_by('-data_scadenza')[:5]
|
||||||
|
|
||||||
|
return render(request, 'diario/dashboard.html', {
|
||||||
|
'eventi': eventi,
|
||||||
|
'obiettivi_aperti': obiettivi_aperti,
|
||||||
|
'scadenze_prossime': scadenze_prossime,
|
||||||
|
'scaduti': scaduti,
|
||||||
|
'oggi': oggi,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Agenda ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def agenda(request):
|
||||||
|
oggi = timezone.now().date()
|
||||||
|
|
||||||
|
# Prossimi eventi: scadenze obiettivi, conversazioni programmate nel futuro
|
||||||
|
scadenze_future = Obiettivo.objects.filter(
|
||||||
|
data_scadenza__gte=oggi,
|
||||||
|
).exclude(stato='completato').order_by('data_scadenza')
|
||||||
|
|
||||||
|
conversazioni_future = Conversazione.objects.filter(
|
||||||
|
data__date__gte=oggi,
|
||||||
|
).select_related('registrato_da').order_by('data')
|
||||||
|
|
||||||
|
# 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],
|
||||||
|
),
|
||||||
|
key=lambda x: x['data'],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Eventi passati (ultimi 30 giorni)
|
||||||
|
data_inizio = oggi - timedelta(days=30)
|
||||||
|
conversazioni_passate = Conversazione.objects.filter(
|
||||||
|
data__date__gte=data_inizio,
|
||||||
|
data__date__lt=oggi,
|
||||||
|
).select_related('registrato_da').order_by('-data')
|
||||||
|
|
||||||
|
scadenze_passate = Obiettivo.objects.filter(
|
||||||
|
data_scadenza__gte=data_inizio,
|
||||||
|
data_scadenza__lt=oggi,
|
||||||
|
).order_by('-data_scadenza')
|
||||||
|
|
||||||
|
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],
|
||||||
|
),
|
||||||
|
key=lambda x: x['data'],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(request, 'diario/agenda.html', {
|
||||||
|
'eventi_futuri': eventi_futuri,
|
||||||
|
'eventi_passati': eventi_passati,
|
||||||
|
'oggi': oggi,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Conversazioni ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def conversazioni_lista(request):
|
||||||
|
qs = Conversazione.objects.select_related('registrato_da').order_by('-data')
|
||||||
|
return render(request, 'diario/conversazioni/lista.html', {'conversazioni': qs})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def conversazione_nuova(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ConversazioneForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
conv = form.save(commit=False)
|
||||||
|
conv.registrato_da = request.user
|
||||||
|
conv.save()
|
||||||
|
form.save_m2m()
|
||||||
|
messages.success(request, 'Conversazione registrata.')
|
||||||
|
return redirect('conversazione_dettaglio', pk=conv.pk)
|
||||||
|
else:
|
||||||
|
form = ConversazioneForm()
|
||||||
|
return render(request, 'diario/conversazioni/form.html', {'form': form, 'titolo_pagina': 'Nuova conversazione'})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
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()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
comment_form = CommentoConversazioneForm(request.POST)
|
||||||
|
if comment_form.is_valid():
|
||||||
|
c = comment_form.save(commit=False)
|
||||||
|
c.conversazione = conv
|
||||||
|
c.autore = request.user
|
||||||
|
c.save()
|
||||||
|
messages.success(request, 'Commento aggiunto.')
|
||||||
|
return redirect('conversazione_dettaglio', pk=conv.pk)
|
||||||
|
else:
|
||||||
|
comment_form = CommentoConversazioneForm()
|
||||||
|
|
||||||
|
return render(request, 'diario/conversazioni/dettaglio.html', {
|
||||||
|
'conv': conv,
|
||||||
|
'commenti': commenti,
|
||||||
|
'comment_form': comment_form,
|
||||||
|
'documenti': documenti,
|
||||||
|
'can_edit': _can_edit(request.user, conv),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def conversazione_modifica(request, pk):
|
||||||
|
conv = get_object_or_404(Conversazione, pk=pk)
|
||||||
|
if not _can_edit(request.user, conv):
|
||||||
|
return HttpResponseForbidden('Non hai i permessi per modificare questa conversazione.')
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ConversazioneForm(request.POST, instance=conv)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Conversazione aggiornata.')
|
||||||
|
return redirect('conversazione_dettaglio', pk=conv.pk)
|
||||||
|
else:
|
||||||
|
form = ConversazioneForm(instance=conv)
|
||||||
|
return render(request, 'diario/conversazioni/form.html', {'form': form, 'titolo_pagina': 'Modifica conversazione', 'conv': conv})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def conversazione_elimina(request, pk):
|
||||||
|
conv = get_object_or_404(Conversazione, pk=pk)
|
||||||
|
if not _can_edit(request.user, conv):
|
||||||
|
return HttpResponseForbidden('Non hai i permessi per eliminare questa conversazione.')
|
||||||
|
if request.method == 'POST':
|
||||||
|
conv.delete()
|
||||||
|
messages.success(request, 'Conversazione eliminata.')
|
||||||
|
return redirect('conversazioni_lista')
|
||||||
|
return render(request, 'diario/conferma_elimina.html', {
|
||||||
|
'oggetto': conv,
|
||||||
|
'tipo': 'conversazione',
|
||||||
|
'cancel_url': 'conversazione_dettaglio',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commenti conversazione — modifica/elimina ─────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def commento_modifica(request, pk):
|
||||||
|
commento = get_object_or_404(CommentoConversazione, pk=pk)
|
||||||
|
if not _can_edit(request.user, commento):
|
||||||
|
return HttpResponseForbidden('Non puoi modificare questo commento.')
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = CommentoConversazioneForm(request.POST, instance=commento)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Commento aggiornato.')
|
||||||
|
return redirect('conversazione_dettaglio', pk=commento.conversazione.pk)
|
||||||
|
else:
|
||||||
|
form = CommentoConversazioneForm(instance=commento)
|
||||||
|
return render(request, 'diario/commento_modifica.html', {
|
||||||
|
'form': form,
|
||||||
|
'commento': commento,
|
||||||
|
'tipo': 'commento',
|
||||||
|
'back_url': redirect('conversazione_dettaglio', pk=commento.conversazione.pk).url,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def commento_elimina(request, pk):
|
||||||
|
commento = get_object_or_404(CommentoConversazione, pk=pk)
|
||||||
|
if not _can_edit(request.user, commento):
|
||||||
|
return HttpResponseForbidden('Non puoi eliminare questo commento.')
|
||||||
|
conv_pk = commento.conversazione.pk
|
||||||
|
if request.method == 'POST':
|
||||||
|
commento.delete()
|
||||||
|
messages.success(request, 'Commento eliminato.')
|
||||||
|
return redirect('conversazione_dettaglio', pk=conv_pk)
|
||||||
|
return render(request, 'diario/conferma_elimina.html', {
|
||||||
|
'oggetto': commento,
|
||||||
|
'tipo': 'commento',
|
||||||
|
'cancel_url': 'conversazione_dettaglio',
|
||||||
|
'cancel_pk': conv_pk,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Aggiornamenti obiettivo — modifica/elimina ────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def aggiornamento_modifica(request, pk):
|
||||||
|
agg = get_object_or_404(AggiornamentoObiettivo, pk=pk)
|
||||||
|
if not _can_edit(request.user, agg):
|
||||||
|
return HttpResponseForbidden('Non puoi modificare questo aggiornamento.')
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = AggiornamentoObiettivoForm(request.POST, instance=agg)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Aggiornamento modificato.')
|
||||||
|
return redirect('obiettivo_dettaglio', pk=agg.obiettivo.pk)
|
||||||
|
else:
|
||||||
|
form = AggiornamentoObiettivoForm(instance=agg)
|
||||||
|
return render(request, 'diario/commento_modifica.html', {
|
||||||
|
'form': form,
|
||||||
|
'commento': agg,
|
||||||
|
'tipo': 'aggiornamento',
|
||||||
|
'back_url': redirect('obiettivo_dettaglio', pk=agg.obiettivo.pk).url,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def aggiornamento_elimina(request, pk):
|
||||||
|
agg = get_object_or_404(AggiornamentoObiettivo, pk=pk)
|
||||||
|
if not _can_edit(request.user, agg):
|
||||||
|
return HttpResponseForbidden('Non puoi eliminare questo aggiornamento.')
|
||||||
|
obj_pk = agg.obiettivo.pk
|
||||||
|
if request.method == 'POST':
|
||||||
|
agg.delete()
|
||||||
|
messages.success(request, 'Aggiornamento eliminato.')
|
||||||
|
return redirect('obiettivo_dettaglio', pk=obj_pk)
|
||||||
|
return render(request, 'diario/conferma_elimina.html', {
|
||||||
|
'oggetto': agg,
|
||||||
|
'tipo': 'aggiornamento',
|
||||||
|
'cancel_url': 'obiettivo_dettaglio',
|
||||||
|
'cancel_pk': obj_pk,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Obiettivi ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def obiettivi_lista(request):
|
||||||
|
filtro = request.GET.get('filtro', 'tutti')
|
||||||
|
qs = Obiettivo.objects.prefetch_related('assegnato_a').select_related('creato_da')
|
||||||
|
|
||||||
|
if filtro == 'collettivi':
|
||||||
|
qs = qs.filter(tipo='collettivo')
|
||||||
|
elif filtro == 'individuali':
|
||||||
|
qs = qs.filter(tipo='individuale')
|
||||||
|
elif filtro == 'miei':
|
||||||
|
qs = qs.filter(assegnato_a=request.user)
|
||||||
|
|
||||||
|
return render(request, 'diario/obiettivi/lista.html', {'obiettivi': qs, 'filtro': filtro})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def obiettivo_nuovo(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ObiettivoForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
obj = form.save(commit=False)
|
||||||
|
obj.creato_da = request.user
|
||||||
|
obj.save()
|
||||||
|
form.save_m2m()
|
||||||
|
messages.success(request, 'Obiettivo creato.')
|
||||||
|
return redirect('obiettivo_dettaglio', pk=obj.pk)
|
||||||
|
else:
|
||||||
|
form = ObiettivoForm()
|
||||||
|
return render(request, 'diario/obiettivi/form.html', {'form': form, 'titolo_pagina': 'Nuovo obiettivo'})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def obiettivo_dettaglio(request, pk):
|
||||||
|
obj = get_object_or_404(Obiettivo, pk=pk)
|
||||||
|
aggiornamenti = obj.aggiornamenti.select_related('autore').order_by('-data')
|
||||||
|
documenti = obj.documenti.select_related('caricato_da').all()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
agg_form = AggiornamentoObiettivoForm(request.POST)
|
||||||
|
if agg_form.is_valid():
|
||||||
|
agg = agg_form.save(commit=False)
|
||||||
|
agg.obiettivo = obj
|
||||||
|
agg.autore = request.user
|
||||||
|
agg.save()
|
||||||
|
messages.success(request, 'Aggiornamento aggiunto.')
|
||||||
|
return redirect('obiettivo_dettaglio', pk=obj.pk)
|
||||||
|
else:
|
||||||
|
agg_form = AggiornamentoObiettivoForm()
|
||||||
|
|
||||||
|
return render(request, 'diario/obiettivi/dettaglio.html', {
|
||||||
|
'obj': obj,
|
||||||
|
'aggiornamenti': aggiornamenti,
|
||||||
|
'documenti': documenti,
|
||||||
|
'agg_form': agg_form,
|
||||||
|
'can_edit': _can_edit(request.user, obj),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def obiettivo_modifica(request, pk):
|
||||||
|
obj = get_object_or_404(Obiettivo, pk=pk)
|
||||||
|
if not _can_edit(request.user, obj):
|
||||||
|
return HttpResponseForbidden('Non hai i permessi per modificare questo obiettivo.')
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ObiettivoForm(request.POST, instance=obj)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Obiettivo aggiornato.')
|
||||||
|
return redirect('obiettivo_dettaglio', pk=obj.pk)
|
||||||
|
else:
|
||||||
|
form = ObiettivoForm(instance=obj)
|
||||||
|
return render(request, 'diario/obiettivi/form.html', {'form': form, 'titolo_pagina': 'Modifica obiettivo', 'obj': obj})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def obiettivo_elimina(request, pk):
|
||||||
|
obj = get_object_or_404(Obiettivo, pk=pk)
|
||||||
|
if not _can_edit(request.user, obj):
|
||||||
|
return HttpResponseForbidden('Non hai i permessi per eliminare questo obiettivo.')
|
||||||
|
if request.method == 'POST':
|
||||||
|
obj.delete()
|
||||||
|
messages.success(request, 'Obiettivo eliminato.')
|
||||||
|
return redirect('obiettivi_lista')
|
||||||
|
return render(request, 'diario/conferma_elimina.html', {
|
||||||
|
'oggetto': obj,
|
||||||
|
'tipo': 'obiettivo',
|
||||||
|
'cancel_url': 'obiettivo_dettaglio',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def obiettivo_avanzamento_ajax(request, pk):
|
||||||
|
obj = get_object_or_404(Obiettivo, pk=pk)
|
||||||
|
try:
|
||||||
|
valore = int(request.POST.get('avanzamento', 0))
|
||||||
|
if not 0 <= valore <= 100:
|
||||||
|
return JsonResponse({'ok': False, 'error': 'Valore fuori range'}, status=400)
|
||||||
|
obj.avanzamento = valore
|
||||||
|
obj.save(update_fields=['avanzamento'])
|
||||||
|
return JsonResponse({'ok': True, 'avanzamento': obj.avanzamento})
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return JsonResponse({'ok': False, 'error': 'Valore non valido'}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Documenti ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def documenti_lista(request):
|
||||||
|
qs = Documento.objects.select_related('caricato_da', 'conversazione', 'obiettivo').order_by('-data_caricamento')
|
||||||
|
return render(request, 'diario/documenti/lista.html', {'documenti': qs})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def documento_nuovo(request):
|
||||||
|
conversazione_pk = request.GET.get('conversazione')
|
||||||
|
obiettivo_pk = request.GET.get('obiettivo')
|
||||||
|
initial = {}
|
||||||
|
if conversazione_pk:
|
||||||
|
initial['conversazione'] = conversazione_pk
|
||||||
|
if obiettivo_pk:
|
||||||
|
initial['obiettivo'] = obiettivo_pk
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = DocumentoForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
doc = form.save(commit=False)
|
||||||
|
doc.caricato_da = request.user
|
||||||
|
doc.save()
|
||||||
|
messages.success(request, 'Documento caricato.')
|
||||||
|
if doc.conversazione:
|
||||||
|
return redirect('conversazione_dettaglio', pk=doc.conversazione.pk)
|
||||||
|
if doc.obiettivo:
|
||||||
|
return redirect('obiettivo_dettaglio', pk=doc.obiettivo.pk)
|
||||||
|
return redirect('documento_dettaglio', pk=doc.pk)
|
||||||
|
else:
|
||||||
|
form = DocumentoForm(initial=initial)
|
||||||
|
return render(request, 'diario/documenti/form.html', {'form': form, 'titolo_pagina': 'Carica documento'})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def documento_dettaglio(request, pk):
|
||||||
|
doc = get_object_or_404(Documento.objects.select_related('caricato_da', 'conversazione', 'obiettivo'), pk=pk)
|
||||||
|
return render(request, 'diario/documenti/dettaglio.html', {
|
||||||
|
'doc': doc,
|
||||||
|
'can_edit': _can_edit(request.user, doc),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def documento_elimina(request, pk):
|
||||||
|
doc = get_object_or_404(Documento, pk=pk)
|
||||||
|
if not _can_edit(request.user, doc):
|
||||||
|
return HttpResponseForbidden('Non hai i permessi per eliminare questo documento.')
|
||||||
|
if request.method == 'POST':
|
||||||
|
doc.file.delete(save=False)
|
||||||
|
doc.delete()
|
||||||
|
messages.success(request, 'Documento eliminato.')
|
||||||
|
return redirect('documenti_lista')
|
||||||
|
return render(request, 'diario/conferma_elimina.html', {
|
||||||
|
'oggetto': doc,
|
||||||
|
'tipo': 'documento',
|
||||||
|
'cancel_url': 'documento_dettaglio',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ricerca ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def ricerca(request):
|
||||||
|
q = request.GET.get('q', '').strip()
|
||||||
|
risultati = {'conversazioni': [], 'obiettivi': [], 'documenti': [], 'persone': []}
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
if len(q) >= 2:
|
||||||
|
risultati['conversazioni'] = Conversazione.objects.filter(
|
||||||
|
Q(titolo__icontains=q) | Q(contenuto__icontains=q)
|
||||||
|
).select_related('registrato_da')[:20]
|
||||||
|
|
||||||
|
risultati['obiettivi'] = Obiettivo.objects.filter(
|
||||||
|
Q(titolo__icontains=q) | Q(descrizione__icontains=q)
|
||||||
|
).select_related('creato_da')[:20]
|
||||||
|
|
||||||
|
risultati['documenti'] = Documento.objects.filter(
|
||||||
|
Q(titolo__icontains=q) | Q(descrizione__icontains=q)
|
||||||
|
).select_related('caricato_da')[:20]
|
||||||
|
|
||||||
|
risultati['persone'] = User.objects.filter(
|
||||||
|
Q(first_name__icontains=q) | Q(last_name__icontains=q) | Q(username__icontains=q),
|
||||||
|
is_active=True,
|
||||||
|
)[:20]
|
||||||
|
|
||||||
|
total = sum(len(v) if hasattr(v, '__len__') else v.count() for v in risultati.values())
|
||||||
|
|
||||||
|
ctx = {'q': q, 'risultati': risultati, 'total': total}
|
||||||
|
|
||||||
|
if getattr(request, 'htmx', False):
|
||||||
|
return render(request, 'diario/ricerca_risultati.html', ctx)
|
||||||
|
|
||||||
|
return render(request, 'diario/ricerca.html', ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Persone ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def persone_lista(request):
|
||||||
|
persone = User.objects.filter(is_active=True).annotate(
|
||||||
|
num_conversazioni=Count('conversazioni_registrate', distinct=True),
|
||||||
|
num_obiettivi=Count('obiettivi_creati', distinct=True),
|
||||||
|
num_documenti=Count('documenti_caricati', distinct=True),
|
||||||
|
).order_by('first_name', 'username')
|
||||||
|
return render(request, 'diario/persone/lista.html', {'persone': persone})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def persona_dettaglio(request, pk):
|
||||||
|
persona = get_object_or_404(User, pk=pk, is_active=True)
|
||||||
|
conversazioni = Conversazione.objects.filter(
|
||||||
|
Q(registrato_da=persona) | Q(partecipanti=persona)
|
||||||
|
).distinct().select_related('registrato_da').order_by('-data')[:20]
|
||||||
|
|
||||||
|
obiettivi = Obiettivo.objects.filter(
|
||||||
|
Q(creato_da=persona) | Q(assegnato_a=persona)
|
||||||
|
).distinct().select_related('creato_da').prefetch_related('assegnato_a').order_by('-data_creazione')[:20]
|
||||||
|
|
||||||
|
documenti = Documento.objects.filter(
|
||||||
|
caricato_da=persona
|
||||||
|
).select_related('conversazione', 'obiettivo').order_by('-data_caricamento')[:20]
|
||||||
|
|
||||||
|
return render(request, 'diario/persone/dettaglio.html', {
|
||||||
|
'persona': persona,
|
||||||
|
'conversazioni': conversazioni,
|
||||||
|
'obiettivi': obiettivi,
|
||||||
|
'documenti': documenti,
|
||||||
|
})
|
||||||
|
|
||||||
22
manage.py
Executable file
22
manage.py
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'olimpic_nastri.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
21
olimpic-nastri-gunicorn.service
Normal file
21
olimpic-nastri-gunicorn.service
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Gunicorn daemon per Olimpic Progetto Nastri (Django)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=marco
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/home/marco/olimpic_nastri
|
||||||
|
Environment="DJANGO_SETTINGS_MODULE=olimpic_nastri.settings"
|
||||||
|
Environment="OLIMPIC_DB_PASSWORD=OlimpicNastri2026!"
|
||||||
|
ExecStart=/home/marco/olimpic_nastri/nastrivenv/bin/gunicorn \
|
||||||
|
--access-logfile - \
|
||||||
|
--workers 3 \
|
||||||
|
--bind unix:/run/olimpic_nastri/gunicorn.sock \
|
||||||
|
olimpic_nastri.wsgi:application
|
||||||
|
RuntimeDirectory=olimpic_nastri
|
||||||
|
Restart=on-failure
|
||||||
|
TimeoutStartSec=15
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
0
olimpic_nastri/__init__.py
Normal file
0
olimpic_nastri/__init__.py
Normal file
16
olimpic_nastri/asgi.py
Normal file
16
olimpic_nastri/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for olimpic_nastri project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'olimpic_nastri.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
143
olimpic_nastri/settings.py
Normal file
143
olimpic_nastri/settings.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Django settings for olimpic_nastri project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.12.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = 'django-insecure-ip*)m*6n9(-w)6+1x7iezyk(*gvucz*jtwng&=ozi3%uhs=gwa'
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ['diario.olimpic.click', 'localhost', '127.0.0.1']
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'django_htmx',
|
||||||
|
'diario',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
'django_htmx.middleware.HtmxMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'olimpic_nastri.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'olimpic_nastri.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
|
'NAME': 'olimpic_nastri_db',
|
||||||
|
'USER': 'olimpic_nastri',
|
||||||
|
'PASSWORD': os.environ.get('OLIMPIC_DB_PASSWORD', 'OlimpicNastri2026!'),
|
||||||
|
'HOST': 'localhost',
|
||||||
|
'PORT': '5432',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'it-it'
|
||||||
|
|
||||||
|
TIME_ZONE = 'Europe/Rome'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
LOGIN_URL = '/login/'
|
||||||
|
LOGIN_REDIRECT_URL = '/'
|
||||||
|
LOGOUT_REDIRECT_URL = '/login/'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# Max upload size (10 MB)
|
||||||
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
|
||||||
|
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
|
||||||
32
olimpic_nastri/urls.py
Normal file
32
olimpic_nastri/urls.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for olimpic_nastri project.
|
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||||
|
Examples:
|
||||||
|
Function views
|
||||||
|
1. Add an import: from my_app import views
|
||||||
|
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||||
|
Class-based views
|
||||||
|
1. Add an import: from other_app.views import Home
|
||||||
|
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||||
|
Including another URLconf
|
||||||
|
1. Import the include() function: from django.urls import include, path
|
||||||
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('login/', auth_views.LoginView.as_view(template_name='diario/login.html'), name='login'),
|
||||||
|
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||||
|
path('', include('diario.urls')),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
16
olimpic_nastri/wsgi.py
Normal file
16
olimpic_nastri/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for olimpic_nastri project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'olimpic_nastri.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
asgiref==3.11.1
|
||||||
|
Django==5.2.12
|
||||||
|
django-htmx==1.27.0
|
||||||
|
gunicorn==25.3.0
|
||||||
|
packaging==26.0
|
||||||
|
psycopg2-binary==2.9.11
|
||||||
|
sqlparse==0.5.5
|
||||||
|
typing_extensions==4.15.0
|
||||||
329
static/css/style.css
Normal file
329
static/css/style.css
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
/* ── Olimpic Nastri — Style v2 ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bs-body-bg: #f0f2f5;
|
||||||
|
--accent: #4361ee;
|
||||||
|
--accent-soft: #eef0fd;
|
||||||
|
--accent-hover: #3451d1;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--success-bg: #dcfce7;
|
||||||
|
--success-fg: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
font-size: .93rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Navbar ── */
|
||||||
|
.navbar {
|
||||||
|
background: #1a1f36 !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.25);
|
||||||
|
padding-top: .6rem;
|
||||||
|
padding-bottom: .6rem;
|
||||||
|
}
|
||||||
|
.navbar-brand { font-weight: 800; font-size: 1.05rem; letter-spacing: .5px; color: #fff !important; }
|
||||||
|
.navbar-brand span { color: #7c8ff9; }
|
||||||
|
.nav-link { color: rgba(255,255,255,.75) !important; font-weight: 500; transition: color .15s; }
|
||||||
|
.nav-link:hover, .nav-link.active { color: #fff !important; }
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 6px rgba(0,0,0,.07);
|
||||||
|
background: #fff;
|
||||||
|
transition: box-shadow .2s ease, transform .2s ease;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
.card-header-accent {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-bottom: 1px solid #d9ddfc;
|
||||||
|
border-radius: 12px 12px 0 0 !important;
|
||||||
|
padding: .75rem 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: .3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animations ── */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn .3s ease both;
|
||||||
|
}
|
||||||
|
.fade-in-delay-1 { animation-delay: .05s; }
|
||||||
|
.fade-in-delay-2 { animation-delay: .1s; }
|
||||||
|
.fade-in-delay-3 { animation-delay: .15s; }
|
||||||
|
|
||||||
|
/* HTMX transitions */
|
||||||
|
.htmx-settling {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.htmx-settling .container-lg,
|
||||||
|
.htmx-settling .fade-in {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
#main-content {
|
||||||
|
transition: opacity .15s ease;
|
||||||
|
}
|
||||||
|
#main-content.htmx-swapping {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Timeline ── */
|
||||||
|
.timeline { position: relative; padding-left: 28px; }
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; left: 10px; top: 4px; bottom: 4px;
|
||||||
|
width: 2px; background: #e0e4f0; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.tl-item { position: relative; margin-bottom: 1.4rem; }
|
||||||
|
.tl-dot {
|
||||||
|
position: absolute; left: -24px; top: 3px;
|
||||||
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 2px currentColor;
|
||||||
|
}
|
||||||
|
.tl-dot.conv { color: var(--accent); background: var(--accent); }
|
||||||
|
.tl-dot.agg { color: #f97316; background: #f97316; }
|
||||||
|
.tl-dot.doc { color: #10b981; background: #10b981; }
|
||||||
|
|
||||||
|
/* ── Badge stati ── */
|
||||||
|
.badge-stato {
|
||||||
|
font-size: .7rem; font-weight: 600;
|
||||||
|
padding: .3em .65em; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.stato-aperto { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.stato-in_corso { background: #ffedd5; color: #c2410c; }
|
||||||
|
.stato-completato{ background: #dcfce7; color: #15803d; }
|
||||||
|
.stato-sospeso { background: #f1f5f9; color: #64748b; }
|
||||||
|
|
||||||
|
/* ── Progress slider AJAX ── */
|
||||||
|
.slider-wrap { position: relative; }
|
||||||
|
.progress-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%; height: 6px;
|
||||||
|
border-radius: 4px; outline: none; cursor: pointer;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--accent) 0%,
|
||||||
|
var(--accent) var(--val, 0%),
|
||||||
|
#dee2e6 var(--val, 0%),
|
||||||
|
#dee2e6 100%
|
||||||
|
);
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.progress-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
border-radius: 50%; cursor: pointer;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 3px solid #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(67,97,238,.5);
|
||||||
|
transition: transform .15s;
|
||||||
|
}
|
||||||
|
.progress-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
.slider-label {
|
||||||
|
font-size: .78rem; font-weight: 700; color: var(--accent);
|
||||||
|
min-width: 36px; text-align: right;
|
||||||
|
}
|
||||||
|
.slider-saving { font-size: .72rem; color: #94a3b8; }
|
||||||
|
|
||||||
|
/* ── Pill tipo ── */
|
||||||
|
.pill-collettivo { background:#ede9fe; color:#6d28d9; }
|
||||||
|
.pill-individuale{ background:#fce7f3; color:#9d174d; }
|
||||||
|
.pill-tipo { font-size:.7rem; font-weight:600; padding:.25em .6em; border-radius:20px; }
|
||||||
|
|
||||||
|
/* ── Misc ── */
|
||||||
|
.section-title {
|
||||||
|
font-weight: 700; font-size: .8rem; text-transform: uppercase;
|
||||||
|
letter-spacing: .8px; color: #94a3b8; margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
.btn-icon {
|
||||||
|
width: 32px; height: 32px; padding: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
font-size: .7rem; font-weight: 700;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.avatar-lg {
|
||||||
|
width: 48px; height: 48px; font-size: 1rem;
|
||||||
|
}
|
||||||
|
hr.soft { border-color: #f1f3f9; }
|
||||||
|
|
||||||
|
/* ── Forms ── */
|
||||||
|
textarea.form-control, input.form-control { border-radius: 10px; border-color: #e2e8f0; }
|
||||||
|
textarea.form-control:focus, input.form-control:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(67,97,238,.12); }
|
||||||
|
.form-select { border-radius: 10px; border-color: #e2e8f0; }
|
||||||
|
.form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(67,97,238,.12); }
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.btn-primary { background: var(--accent); border-color: var(--accent); border-radius: 9px; font-weight: 600; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||||
|
.btn-outline-secondary { border-radius: 9px; }
|
||||||
|
.btn-outline-danger { border-radius: 9px; }
|
||||||
|
|
||||||
|
/* ── Search bar ── */
|
||||||
|
.search-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.search-wrapper .form-control {
|
||||||
|
padding-left: 2.2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255,255,255,.1);
|
||||||
|
border-color: rgba(255,255,255,.15);
|
||||||
|
color: #fff;
|
||||||
|
font-size: .85rem;
|
||||||
|
transition: background .2s, border-color .2s, width .3s;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
.search-wrapper .form-control::placeholder { color: rgba(255,255,255,.5); }
|
||||||
|
.search-wrapper .form-control:focus {
|
||||||
|
background: rgba(255,255,255,.18);
|
||||||
|
border-color: rgba(255,255,255,.3);
|
||||||
|
color: #fff;
|
||||||
|
width: 280px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.search-wrapper .search-icon {
|
||||||
|
position: absolute; left: .75rem; top: 50%; transform: translateY(-50%);
|
||||||
|
color: rgba(255,255,255,.5); font-size: .8rem; pointer-events: none;
|
||||||
|
}
|
||||||
|
.search-results-dropdown {
|
||||||
|
position: absolute; top: 100%; left: 0; right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: #fff; border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.15);
|
||||||
|
z-index: 1050; max-height: 400px; overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.search-results-dropdown.show { display: block; }
|
||||||
|
.search-results-dropdown .result-item {
|
||||||
|
padding: .6rem 1rem; cursor: pointer;
|
||||||
|
transition: background .1s;
|
||||||
|
color: #1a1f36; text-decoration: none; display: block;
|
||||||
|
}
|
||||||
|
.search-results-dropdown .result-item:hover { background: var(--accent-soft); }
|
||||||
|
.search-results-dropdown .result-type {
|
||||||
|
font-size: .65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
|
||||||
|
color: var(--accent); margin-bottom: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toast container ── */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 1090;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
border-radius: 10px; border: none;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,.12);
|
||||||
|
animation: fadeIn .25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Person card ── */
|
||||||
|
.person-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
.person-card .avatar { margin-bottom: .75rem; }
|
||||||
|
.person-card .stat { font-size: .75rem; color: #94a3b8; }
|
||||||
|
.person-card .stat-num { font-weight: 700; color: var(--accent); font-size: .9rem; }
|
||||||
|
|
||||||
|
/* ── Document badge ── */
|
||||||
|
.doc-badge {
|
||||||
|
background: #ecfdf5; color: #059669;
|
||||||
|
font-size: .7rem; font-weight: 600;
|
||||||
|
padding: .25em .6em; border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Clickable card ── */
|
||||||
|
.card-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.card-link:hover .card {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ── */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center; padding: 3rem 1rem; color: #94a3b8;
|
||||||
|
}
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 2.5rem; opacity: .3; margin-bottom: .75rem; display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Countdown badges ── */
|
||||||
|
.countdown {
|
||||||
|
font-size: .7rem; font-weight: 600;
|
||||||
|
padding: .2em .55em; border-radius: 6px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.countdown-urgent {
|
||||||
|
background: #fef2f2; color: #dc2626;
|
||||||
|
}
|
||||||
|
.countdown-soon {
|
||||||
|
background: #fff7ed; color: #c2410c;
|
||||||
|
}
|
||||||
|
.countdown-ok {
|
||||||
|
background: #ecfdf5; color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Agenda ── */
|
||||||
|
.agenda-row {
|
||||||
|
transition: background .15s ease;
|
||||||
|
}
|
||||||
|
.agenda-row:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.agenda-row-scaduto {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
.agenda-row-scaduto:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
.agenda-date {
|
||||||
|
width: 42px;
|
||||||
|
}
|
||||||
|
.agenda-day {
|
||||||
|
font-size: 1.1rem; font-weight: 700; line-height: 1.1; color: #1a1f36;
|
||||||
|
}
|
||||||
|
.agenda-month {
|
||||||
|
font-size: .6rem; text-transform: uppercase; letter-spacing: .5px; color: #94a3b8;
|
||||||
|
}
|
||||||
|
.agenda-icon {
|
||||||
|
width: 32px; height: 32px; border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: .85rem; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.agenda-icon-scadenza {
|
||||||
|
background: var(--accent-soft); color: var(--accent);
|
||||||
|
}
|
||||||
|
.agenda-icon-conv {
|
||||||
|
background: #f0f9ff; color: #0284c7;
|
||||||
|
}
|
||||||
|
.agenda-icon-danger {
|
||||||
|
background: #fef2f2; color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Comment actions dropdown ── */
|
||||||
|
.card .dropdown .btn-icon {
|
||||||
|
opacity: 0; transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.card:hover .dropdown .btn-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
157
static/js/app.js
Normal file
157
static/js/app.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/* ── Olimpic Nastri — App JS v2 ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// ── CSRF helper ──
|
||||||
|
function getCsrf() {
|
||||||
|
return document.cookie.split('; ')
|
||||||
|
.find(r => r.startsWith('csrftoken='))
|
||||||
|
?.split('=')[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast notifications ──
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: 'bi-check-circle-fill',
|
||||||
|
danger: 'bi-exclamation-triangle-fill',
|
||||||
|
info: 'bi-info-circle-fill',
|
||||||
|
};
|
||||||
|
const colors = {
|
||||||
|
success: '#15803d',
|
||||||
|
danger: '#dc2626',
|
||||||
|
info: '#4361ee',
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = 'toast-' + Date.now();
|
||||||
|
const html = `
|
||||||
|
<div id="${id}" class="toast align-items-center text-white border-0 mb-2" role="alert"
|
||||||
|
style="background:${colors[type] || colors.success};">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body d-flex align-items-center gap-2">
|
||||||
|
<i class="bi ${icons[type] || icons.success}"></i>
|
||||||
|
${message}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
const toastEl = document.getElementById(id);
|
||||||
|
const bsToast = new bootstrap.Toast(toastEl, { delay: 3000 });
|
||||||
|
bsToast.show();
|
||||||
|
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convert Django messages to toasts on page load ──
|
||||||
|
function convertMessagesToToasts() {
|
||||||
|
document.querySelectorAll('[data-toast-message]').forEach(el => {
|
||||||
|
showToast(el.dataset.toastMessage, el.dataset.toastType || 'success');
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress slider AJAX ──
|
||||||
|
function initSliders() {
|
||||||
|
document.querySelectorAll('.progress-slider').forEach(slider => {
|
||||||
|
if (slider._sliderInit) return;
|
||||||
|
slider._sliderInit = true;
|
||||||
|
|
||||||
|
const id = slider.dataset.id;
|
||||||
|
const lbl = document.getElementById('lbl-' + id);
|
||||||
|
const saving = document.getElementById('saving-' + id);
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
slider.addEventListener('input', () => {
|
||||||
|
const v = slider.value;
|
||||||
|
if (lbl) lbl.textContent = v + '%';
|
||||||
|
slider.style.setProperty('--val', v + '%');
|
||||||
|
});
|
||||||
|
|
||||||
|
slider.addEventListener('change', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (saving) {
|
||||||
|
saving.classList.remove('d-none');
|
||||||
|
saving.textContent = 'salvataggio…';
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
fetch(slider.dataset.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrf(),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: 'avanzamento=' + slider.value,
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (saving) {
|
||||||
|
saving.textContent = d.ok ? '✓ salvato' : '⚠ errore';
|
||||||
|
setTimeout(() => saving.classList.add('d-none'), 1500);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (saving) {
|
||||||
|
saving.textContent = '⚠ errore';
|
||||||
|
setTimeout(() => saving.classList.add('d-none'), 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live search dropdown ──
|
||||||
|
function initLiveSearch() {
|
||||||
|
const input = document.getElementById('search-input');
|
||||||
|
const dropdown = document.getElementById('search-dropdown');
|
||||||
|
if (!input || !dropdown) return;
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const q = input.value.trim();
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (q.length < 2) {
|
||||||
|
dropdown.classList.remove('show');
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
fetch('/ricerca/?q=' + encodeURIComponent(q), {
|
||||||
|
headers: { 'HX-Request': 'true' }
|
||||||
|
})
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(html => {
|
||||||
|
dropdown.innerHTML = html;
|
||||||
|
dropdown.classList.add('show');
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown on click outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!input.contains(e.target) && !dropdown.contains(e.target)) {
|
||||||
|
dropdown.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to full search on Enter
|
||||||
|
input.closest('form')?.addEventListener('submit', (e) => {
|
||||||
|
dropdown.classList.remove('show');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init everything ──
|
||||||
|
function initApp() {
|
||||||
|
convertMessagesToToasts();
|
||||||
|
initSliders();
|
||||||
|
initLiveSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on initial page load
|
||||||
|
document.addEventListener('DOMContentLoaded', initApp);
|
||||||
|
|
||||||
|
// Re-init after HTMX swaps
|
||||||
|
document.addEventListener('htmx:afterSettle', initApp);
|
||||||
164
templates/diario/agenda.html
Normal file
164
templates/diario/agenda.html
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% load custom_filters %}
|
||||||
|
{% block title %}Agenda – Olimpic Nastri{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-9">
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4 fade-in">
|
||||||
|
<div>
|
||||||
|
<p class="section-title mb-0">Agenda</p>
|
||||||
|
<small class="text-muted">Panoramica di scadenze e appuntamenti</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'obiettivo_nuovo' %}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Obiettivo
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'conversazione_nuova' %}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Conversazione
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Prossimi eventi ── -->
|
||||||
|
<div class="card mb-4 fade-in">
|
||||||
|
<div class="card-header-accent d-flex align-items-center">
|
||||||
|
<i class="bi bi-arrow-right-circle me-2"></i>
|
||||||
|
<span>Prossimi eventi</span>
|
||||||
|
<span class="badge bg-primary bg-opacity-10 text-primary ms-2">{{ eventi_futuri|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-0">
|
||||||
|
{% for ev in eventi_futuri %}
|
||||||
|
<div class="d-flex align-items-center gap-3 px-4 py-3 {% if not forloop.last %}border-bottom{% endif %} agenda-row">
|
||||||
|
<!-- Data -->
|
||||||
|
<div class="agenda-date text-center flex-shrink-0">
|
||||||
|
<div class="agenda-day">{{ ev.data|date:"d" }}</div>
|
||||||
|
<div class="agenda-month">{{ ev.data|date:"M" }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icona tipo -->
|
||||||
|
{% if ev.tipo == 'scadenza' %}
|
||||||
|
<div class="agenda-icon agenda-icon-scadenza">
|
||||||
|
<i class="bi bi-bullseye"></i>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="agenda-icon agenda-icon-conv">
|
||||||
|
<i class="bi bi-chat-quote"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Dettaglio -->
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
{% if ev.tipo == 'scadenza' %}
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' ev.obj.pk %}" class="text-decoration-none text-dark fw-semibold d-block">
|
||||||
|
{{ ev.obj.titolo }}
|
||||||
|
</a>
|
||||||
|
<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="pill-tipo pill-{{ ev.obj.tipo }}">{{ ev.obj.get_tipo_display }}</span>
|
||||||
|
{% with days=ev.obj.giorni_rimanenti %}
|
||||||
|
{% if days == 0 %}
|
||||||
|
<span class="countdown countdown-urgent">Oggi!</span>
|
||||||
|
{% elif days == 1 %}
|
||||||
|
<span class="countdown countdown-urgent">Domani</span>
|
||||||
|
{% elif days <= 7 %}
|
||||||
|
<span class="countdown countdown-soon">{{ days }} giorni</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="countdown countdown-ok">{{ days }} giorni</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'conversazione_dettaglio' ev.obj.pk %}" class="text-decoration-none text-dark fw-semibold d-block">
|
||||||
|
{{ 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|date:"H:i" }}
|
||||||
|
</small>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ ev.obj.registrato_da.get_full_name|default:ev.obj.registrato_da.username }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress (se scadenza) -->
|
||||||
|
{% if ev.tipo == 'scadenza' %}
|
||||||
|
<div class="d-none d-md-block" style="width:80px;">
|
||||||
|
<div class="progress" style="height:6px; border-radius:3px;">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
style="width:{{ ev.obj.avanzamento }}%; background:var(--accent);">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block text-center mt-1" style="font-size:.7rem;">{{ ev.obj.avanzamento }}%</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty-state py-4">
|
||||||
|
<i class="bi bi-calendar-check"></i>
|
||||||
|
<p>Nessun evento programmato. Tutto in ordine!</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Eventi passati ── -->
|
||||||
|
<div class="card fade-in">
|
||||||
|
<div class="card-header-accent d-flex align-items-center" style="background:#f8f9fa; color:#64748b; border-bottom-color:#e2e8f0;">
|
||||||
|
<i class="bi bi-clock-history me-2"></i>
|
||||||
|
<span>Ultimi 30 giorni</span>
|
||||||
|
<span class="badge bg-secondary bg-opacity-10 text-secondary ms-2">{{ eventi_passati|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-0">
|
||||||
|
{% for ev in eventi_passati %}
|
||||||
|
<div class="d-flex align-items-center gap-3 px-4 py-3 {% if not forloop.last %}border-bottom{% endif %} agenda-row {% if ev.scaduto %}agenda-row-scaduto{% endif %}">
|
||||||
|
<!-- Data -->
|
||||||
|
<div class="agenda-date text-center flex-shrink-0 {% if ev.scaduto %}text-danger{% endif %}">
|
||||||
|
<div class="agenda-day">{{ ev.data|date:"d" }}</div>
|
||||||
|
<div class="agenda-month">{{ ev.data|date:"M" }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icona -->
|
||||||
|
{% if ev.tipo == 'scadenza' %}
|
||||||
|
<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>
|
||||||
|
{% else %}
|
||||||
|
<div class="agenda-icon agenda-icon-conv" style="opacity:.6;">
|
||||||
|
<i class="bi bi-chat-quote"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Dettaglio -->
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
{% if ev.tipo == 'scadenza' %}
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' ev.obj.pk %}" class="text-decoration-none {% if ev.scaduto %}text-danger{% else %}text-dark{% endif %} fw-semibold d-block">
|
||||||
|
{{ ev.obj.titolo }}
|
||||||
|
{% if ev.scaduto %}<small class="text-danger fw-normal">(scaduto)</small>{% endif %}
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'conversazione_dettaglio' ev.obj.pk %}" class="text-decoration-none text-muted fw-semibold d-block">
|
||||||
|
{{ ev.obj.titolo }}
|
||||||
|
</a>
|
||||||
|
<small class="text-muted">{{ ev.obj.registrato_da.get_full_name|default:ev.obj.registrato_da.username }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty-state py-4">
|
||||||
|
<i class="bi bi-clock-history"></i>
|
||||||
|
<p>Nessun evento negli ultimi 30 giorni.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
120
templates/diario/base.html
Normal file
120
templates/diario/base.html
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Olimpic Nastri{% endblock %}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
<link href="{% static 'css/style.css' %}" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body hx-boost="true">
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg mb-4">
|
||||||
|
<div class="container-lg">
|
||||||
|
<a class="navbar-brand" href="{% url 'dashboard' %}">
|
||||||
|
<i class="bi bi-journal-richtext me-2" style="color:#7c8ff9;"></i>Olimpic <span>Nastri</span>
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navMenu">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navMenu">
|
||||||
|
<ul class="navbar-nav me-auto gap-1">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'dashboard' %}">
|
||||||
|
<i class="bi bi-house-door me-1"></i>Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'conversazioni_lista' %}">
|
||||||
|
<i class="bi bi-chat-quote me-1"></i>Conversazioni
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'obiettivi_lista' %}">
|
||||||
|
<i class="bi bi-bullseye me-1"></i>Obiettivi
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'agenda' %}">
|
||||||
|
<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 'documenti_lista' %}">
|
||||||
|
<i class="bi bi-file-earmark-pdf me-1"></i>Documenti
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'persone_lista' %}">
|
||||||
|
<i class="bi bi-people me-1"></i>Persone
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<ul class="navbar-nav align-items-center gap-2">
|
||||||
|
<!-- Search bar -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<i class="bi bi-search search-icon"></i>
|
||||||
|
<form action="{% url 'ricerca' %}" method="get" hx-boost="true">
|
||||||
|
<input type="search" name="q" id="search-input"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
placeholder="Cerca..." autocomplete="off">
|
||||||
|
</form>
|
||||||
|
<div id="search-dropdown" class="search-results-dropdown"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{% url 'conversazione_nuova' %}" class="btn btn-sm btn-primary px-3">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Registra
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle d-flex align-items-center gap-2 px-2" href="#" data-bs-toggle="dropdown" data-bs-auto-close="true">
|
||||||
|
<span class="avatar">{{ user.username|slice:":2"|upper }}</span>
|
||||||
|
<span class="d-none d-sm-inline" style="color:rgba(255,255,255,.85);">{{ user.get_full_name|default:user.username }}</span>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end shadow border-0" style="border-radius:12px; min-width:180px;">
|
||||||
|
{% if user.is_staff %}
|
||||||
|
<li><a class="dropdown-item rounded-3" href="/admin/" hx-boost="false"><i class="bi bi-gear me-2 text-muted"></i>Pannello Admin</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<form method="post" action="{% url 'logout' %}" hx-boost="false">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="dropdown-item rounded-3 text-danger">
|
||||||
|
<i class="bi bi-box-arrow-right me-2"></i>Esci
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-lg" id="main-content">
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div data-toast-message="{{ message }}" data-toast-type="{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %}" class="d-none"></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast container -->
|
||||||
|
<div id="toast-container" class="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<script src="{% static 'js/app.js' %}"></script>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
33
templates/diario/commento_modifica.html
Normal file
33
templates/diario/commento_modifica.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% block title %}Modifica {{ tipo }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6 fade-in">
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-4 gap-3">
|
||||||
|
<a href="{{ back_url }}" 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">Modifica {{ tipo }}</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Testo</label>
|
||||||
|
{{ form.testo }}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary px-4">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Salva
|
||||||
|
</button>
|
||||||
|
<a href="{{ back_url }}" class="btn btn-outline-secondary px-4">Annulla</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
30
templates/diario/conferma_elimina.html
Normal file
30
templates/diario/conferma_elimina.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% block title %}Conferma eliminazione{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6 fade-in">
|
||||||
|
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle" style="font-size:2.5rem; color:#ef4444;"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold mb-2">Elimina {{ tipo }}</h5>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Stai per eliminare <strong>{{ oggetto }}</strong>.<br>
|
||||||
|
Questa azione è irreversibile.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex justify-content-center gap-2">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger px-4">
|
||||||
|
<i class="bi bi-trash me-1"></i>Elimina
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a href="{% if cancel_pk %}{% url cancel_url cancel_pk %}{% else %}{% url cancel_url oggetto.pk %}{% endif %}" class="btn btn-outline-secondary px-4">Annulla</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
122
templates/diario/conversazioni/dettaglio.html
Normal file
122
templates/diario/conversazioni/dettaglio.html
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% block title %}{{ conv.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 'conversazioni_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">{{ conv.titolo }}</h5>
|
||||||
|
{% if can_edit %}
|
||||||
|
<a href="{% url 'conversazione_modifica' conv.pk %}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Modifica
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'conversazione_elimina' conv.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">
|
||||||
|
<div class="d-flex align-items-center gap-3 mb-4">
|
||||||
|
<span class="avatar">{{ conv.registrato_da.username|slice:":2"|upper }}</span>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold small">{{ conv.registrato_da.get_full_name|default:conv.registrato_da.username }}</div>
|
||||||
|
<small class="text-muted">{{ conv.data|date:"d/m/Y \a\l\l\e H:i" }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="white-space:pre-wrap;line-height:1.8;font-size:.93rem;">{{ conv.contenuto }}</div>
|
||||||
|
|
||||||
|
{% if conv.partecipanti.all %}
|
||||||
|
<hr class="soft mt-4 mb-3">
|
||||||
|
<div>
|
||||||
|
<small class="text-muted fw-semibold d-block mb-2">Partecipanti</small>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% for p in conv.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>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documenti allegati -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 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
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for doc in documenti %}
|
||||||
|
<div class="card mb-2 p-3 fade-in">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-file-earmark-pdf" style="font-size:1.5rem;color:#dc3545;"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<a href="{% url 'documento_dettaglio' doc.pk %}" class="text-decoration-none text-dark fw-semibold small d-block">
|
||||||
|
{{ doc.titolo }}
|
||||||
|
</a>
|
||||||
|
<small class="text-muted">{{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }} · {{ doc.data_caricamento|date:"d/m/Y" }}</small>
|
||||||
|
</div>
|
||||||
|
<a href="{{ doc.file.url }}" class="btn btn-sm btn-outline-secondary" hx-boost="false" target="_blank">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted small text-center py-2 fade-in">Nessun documento allegato.</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Commenti -->
|
||||||
|
<p class="section-title mt-4 fade-in"><i class="bi bi-chat-dots me-1"></i>Commenti ({{ commenti|length }})</p>
|
||||||
|
|
||||||
|
<!-- Form nuovo commento -->
|
||||||
|
<div class="card p-3 mb-4 fade-in" style="border-left: 3px solid var(--accent) !important;">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label class="form-label fw-semibold small mb-2">Aggiungi un commento</label>
|
||||||
|
{{ comment_form.testo }}
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm mt-2 px-3">
|
||||||
|
<i class="bi bi-send me-1"></i>Pubblica
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for c in commenti %}
|
||||||
|
<div class="card mb-2 p-3 fade-in">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="avatar" style="width:28px;height:28px;font-size:.65rem;">{{ c.autore.username|slice:":2"|upper }}</span>
|
||||||
|
<span class="fw-semibold small">{{ c.autore.get_full_name|default:c.autore.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<small class="text-muted">{{ c.data|date:"d/m/Y H:i" }}</small>
|
||||||
|
{% if user == c.autore or user.is_superuser %}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-icon btn-outline-secondary" style="width:24px;height:24px;" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-three-dots" style="font-size:.7rem;"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end shadow border-0" style="border-radius:10px; min-width:140px;">
|
||||||
|
<li><a class="dropdown-item small" href="{% url 'commento_modifica' c.pk %}"><i class="bi bi-pencil me-2 text-muted"></i>Modifica</a></li>
|
||||||
|
<li><a class="dropdown-item small text-danger" href="{% url 'commento_elimina' c.pk %}"><i class="bi bi-trash me-2"></i>Elimina</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted small text-center py-3">Nessun commento ancora. Sii il primo!</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
57
templates/diario/conversazioni/form.html
Normal file
57
templates/diario/conversazioni/form.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% 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 'conversazioni_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.label }}</label>
|
||||||
|
{{ form.data }}
|
||||||
|
<div class="form-text">Puoi inserire una data passata per conversazioni già avvenute.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">{{ form.contenuto.label }}</label>
|
||||||
|
{{ form.contenuto }}
|
||||||
|
</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>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Salva</button>
|
||||||
|
{% if conv %}
|
||||||
|
<a href="{% url 'conversazione_dettaglio' conv.pk %}" class="btn btn-outline-secondary">Annulla</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'conversazioni_lista' %}" class="btn btn-outline-secondary">Annulla</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
44
templates/diario/conversazioni/lista.html
Normal file
44
templates/diario/conversazioni/lista.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% block title %}Conversazioni{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4 fade-in">
|
||||||
|
<p class="section-title mb-0">Cronologia conversazioni</p>
|
||||||
|
<a href="{% url 'conversazione_nuova' %}" class="btn btn-primary btn-sm px-3">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Nuova conversazione
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for conv in conversazioni %}
|
||||||
|
<div class="card mb-3 p-3 fade-in">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<a href="{% url 'conversazione_dettaglio' conv.pk %}" class="text-decoration-none text-dark fw-semibold d-block mb-1">
|
||||||
|
{{ conv.titolo }}
|
||||||
|
</a>
|
||||||
|
<div class="d-flex align-items-center gap-3 flex-wrap">
|
||||||
|
<small class="text-muted"><i class="bi bi-calendar3 me-1"></i>{{ conv.data|date:"d/m/Y H:i" }}</small>
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<span class="avatar" style="width:20px;height:20px;font-size:.58rem;">{{ conv.registrato_da.username|slice:":2"|upper }}</span>
|
||||||
|
<small class="text-muted">{{ conv.registrato_da.get_full_name|default:conv.registrato_da.username }}</small>
|
||||||
|
</div>
|
||||||
|
{% if conv.partecipanti.count > 0 %}
|
||||||
|
<small class="text-muted"><i class="bi bi-people me-1"></i>{{ conv.partecipanti.count }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="mb-0 text-muted small mt-2">{{ conv.contenuto|truncatewords:30 }}</p>
|
||||||
|
</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">
|
||||||
|
<i class="bi bi-pencil" style="font-size:.75rem;"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-chat-square-dots"></i>
|
||||||
|
<p>Nessuna conversazione registrata.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
237
templates/diario/dashboard.html
Normal file
237
templates/diario/dashboard.html
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% load custom_filters %}
|
||||||
|
{% block title %}Dashboard – Olimpic Nastri{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row g-4">
|
||||||
|
|
||||||
|
<!-- ── Colonna principale: Timeline ── -->
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 fade-in">
|
||||||
|
<p class="section-title mb-0">Cronologia attività</p>
|
||||||
|
<a href="{% url 'conversazione_nuova' %}" class="btn btn-primary btn-sm px-3">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Nuova conversazione
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline">
|
||||||
|
{% for evento in eventi %}
|
||||||
|
<div class="tl-item fade-in">
|
||||||
|
{% if evento.tipo == 'conversazione' %}
|
||||||
|
<span class="tl-dot conv"></span>
|
||||||
|
<div class="card p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-primary bg-opacity-10 text-primary pill-tipo mb-1">
|
||||||
|
<i class="bi bi-chat-quote me-1"></i>Conversazione
|
||||||
|
</span>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
<a href="{% url 'conversazione_dettaglio' evento.obj.pk %}" class="text-decoration-none text-dark">
|
||||||
|
{{ evento.obj.titolo }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted text-nowrap ms-2">{{ evento.data|date:"d/m H:i" }}</small>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-1 mt-2">{{ evento.obj.contenuto|truncatewords:25 }}</p>
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-1">
|
||||||
|
<span class="avatar" style="width:22px;height:22px;font-size:.62rem;">
|
||||||
|
{{ evento.obj.registrato_da.username|slice:":2"|upper }}
|
||||||
|
</span>
|
||||||
|
<small class="text-muted">{{ evento.obj.registrato_da.get_full_name|default:evento.obj.registrato_da.username }}</small>
|
||||||
|
{% if evento.obj.partecipanti.count > 0 %}
|
||||||
|
<small class="text-muted">· {{ evento.obj.partecipanti.count }} partecipanti</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif evento.tipo == 'documento' %}
|
||||||
|
<span class="tl-dot doc"></span>
|
||||||
|
<div class="card p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<span class="badge mb-1 pill-tipo" style="background:#ecfdf5;color:#059669;">
|
||||||
|
<i class="bi bi-file-earmark-pdf me-1"></i>Documento
|
||||||
|
</span>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
<a href="{% url 'documento_dettaglio' evento.obj.pk %}" class="text-decoration-none text-dark">
|
||||||
|
{{ evento.obj.titolo }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted text-nowrap ms-2">{{ evento.data|date:"d/m H:i" }}</small>
|
||||||
|
</div>
|
||||||
|
{% if evento.obj.descrizione %}
|
||||||
|
<p class="text-muted small mb-1 mt-2">{{ evento.obj.descrizione|truncatewords:20 }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-1">
|
||||||
|
<span class="avatar" style="width:22px;height:22px;font-size:.62rem;">
|
||||||
|
{{ evento.obj.caricato_da.username|slice:":2"|upper }}
|
||||||
|
</span>
|
||||||
|
<small class="text-muted">{{ evento.obj.caricato_da.get_full_name|default:evento.obj.caricato_da.username }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="tl-dot agg"></span>
|
||||||
|
<div class="card p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<span class="badge mb-1 pill-tipo" style="background:#ffedd5;color:#c2410c;">
|
||||||
|
<i class="bi bi-arrow-up-circle me-1"></i>Aggiornamento obiettivo
|
||||||
|
</span>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' evento.obj.obiettivo.pk %}" class="text-decoration-none text-dark">
|
||||||
|
{{ evento.obj.obiettivo.titolo }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted text-nowrap ms-2">{{ evento.data|date:"d/m H:i" }}</small>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-1 mt-2">{{ evento.obj.testo|truncatewords:25 }}</p>
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-1">
|
||||||
|
<span class="avatar" style="width:22px;height:22px;font-size:.62rem;">
|
||||||
|
{{ evento.obj.autore.username|slice:":2"|upper }}
|
||||||
|
</span>
|
||||||
|
<small class="text-muted">{{ evento.obj.autore.get_full_name|default:evento.obj.autore.username }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-hourglass"></i>
|
||||||
|
<p>Nessuna attività ancora. Inizia registrando una conversazione.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Sidebar destra ── -->
|
||||||
|
<div class="col-lg-5">
|
||||||
|
|
||||||
|
<!-- Obiettivi aperti con slider AJAX -->
|
||||||
|
<div class="card mb-4 fade-in">
|
||||||
|
<div class="card-header-accent d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-bullseye me-2"></i>Obiettivi in corso</span>
|
||||||
|
<a href="{% url 'obiettivi_lista' %}" class="small fw-normal" style="color:var(--accent);">Vedi tutti →</a>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
{% for obj in obiettivi_aperti %}
|
||||||
|
<div class="mb-3 {% if not forloop.last %}pb-3 border-bottom{% endif %}">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-dark fw-semibold small">
|
||||||
|
{{ obj.titolo }}
|
||||||
|
</a>
|
||||||
|
<span class="badge-stato stato-{{ obj.stato }}">{{ obj.get_stato_display }}</span>
|
||||||
|
</div>
|
||||||
|
{% if obj.data_scadenza %}
|
||||||
|
<div class="mb-1">
|
||||||
|
{% with days=obj.giorni_rimanenti %}
|
||||||
|
{% if days is not None %}
|
||||||
|
{% if days < 0 %}
|
||||||
|
<span class="countdown countdown-urgent" style="font-size:.65rem;"><i class="bi bi-exclamation-triangle-fill me-1"></i>Scaduto da {{ days|abs_val }} gg</span>
|
||||||
|
{% elif days == 0 %}
|
||||||
|
<span class="countdown countdown-urgent" style="font-size:.65rem;">Scade oggi!</span>
|
||||||
|
{% elif days <= 3 %}
|
||||||
|
<span class="countdown countdown-urgent" style="font-size:.65rem;">{{ days }} gg rimasti</span>
|
||||||
|
{% elif days <= 7 %}
|
||||||
|
<span class="countdown countdown-soon" style="font-size:.65rem;">{{ days }} gg rimasti</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="countdown countdown-ok" style="font-size:.65rem;">{{ days }} gg · {{ obj.data_scadenza|date:"d/m" }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Slider AJAX -->
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="100" step="5"
|
||||||
|
value="{{ obj.avanzamento }}"
|
||||||
|
class="progress-slider flex-grow-1"
|
||||||
|
style="--val: {{ obj.avanzamento }}%;"
|
||||||
|
data-url="{% url 'obiettivo_avanzamento_ajax' obj.pk %}"
|
||||||
|
data-id="{{ obj.pk }}"
|
||||||
|
>
|
||||||
|
<span class="slider-label" id="lbl-{{ obj.pk }}">{{ obj.avanzamento }}%</span>
|
||||||
|
</div>
|
||||||
|
<span class="slider-saving d-none" id="saving-{{ obj.pk }}">salvataggio…</span>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted small text-center py-3 mb-0">Tutti gli obiettivi sono completati!</p>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="{% url 'obiettivo_nuovo' %}" class="btn btn-sm btn-outline-secondary w-100">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Aggiungi obiettivo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agenda rapida -->
|
||||||
|
<div class="card mb-4 fade-in">
|
||||||
|
<div class="card-header-accent d-flex justify-content-between align-items-center" style="background:#fff7ed; color:#c2410c; border-bottom-color:#fed7aa;">
|
||||||
|
<span><i class="bi bi-calendar-week me-2"></i>Prossime scadenze</span>
|
||||||
|
<a href="{% url 'agenda' %}" class="small fw-normal" style="color:#c2410c;">Agenda →</a>
|
||||||
|
</div>
|
||||||
|
<div class="p-0">
|
||||||
|
{% for obj in scadenze_prossime %}
|
||||||
|
<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:var(--accent);">{{ obj.data_scadenza|date:"d" }}</div>
|
||||||
|
<div style="font-size:.6rem; text-transform:uppercase; color:#94a3b8;">{{ obj.data_scadenza|date:"M" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-dark fw-semibold small d-block">
|
||||||
|
{{ obj.titolo|truncatewords:8 }}
|
||||||
|
</a>
|
||||||
|
{% with days=obj.giorni_rimanenti %}
|
||||||
|
{% if days <= 3 %}
|
||||||
|
<span class="countdown countdown-urgent" style="font-size:.6rem;">{{ days }} gg</span>
|
||||||
|
{% elif days <= 7 %}
|
||||||
|
<span class="countdown countdown-soon" style="font-size:.6rem;">{{ days }} gg</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="countdown countdown-ok" style="font-size:.6rem;">{{ days }} gg</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
<div style="width:50px;">
|
||||||
|
<div class="progress" style="height:4px; border-radius:2px;">
|
||||||
|
<div class="progress-bar" style="width:{{ obj.avanzamento }}%; background:var(--accent);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted small text-center py-3 mb-0">Nessuna scadenza prossima</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if scaduti %}
|
||||||
|
<!-- Scaduti -->
|
||||||
|
<div class="card fade-in">
|
||||||
|
<div class="card-header-accent d-flex align-items-center" style="background:#fef2f2; color:#dc2626; border-bottom-color:#fecaca;">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
<span>Obiettivi scaduti</span>
|
||||||
|
<span class="badge bg-danger bg-opacity-10 text-danger ms-2">{{ scaduti|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-0">
|
||||||
|
{% for obj in scaduti %}
|
||||||
|
<div class="d-flex align-items-center gap-3 px-3 py-2 {% if not forloop.last %}border-bottom{% endif %}">
|
||||||
|
<i class="bi bi-exclamation-circle text-danger"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-danger fw-semibold small d-block">
|
||||||
|
{{ obj.titolo|truncatewords:8 }}
|
||||||
|
</a>
|
||||||
|
<small class="text-muted">Scaduto il {{ obj.data_scadenza|date:"d/m/Y" }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
70
templates/diario/documenti/dettaglio.html
Normal file
70
templates/diario/documenti/dettaglio.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% block title %}{{ doc.titolo }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8 fade-in">
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-4 gap-3">
|
||||||
|
<a href="{% url 'documenti_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">{{ doc.titolo }}</h5>
|
||||||
|
{% if can_edit %}
|
||||||
|
<a href="{% url 'documento_elimina' doc.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">
|
||||||
|
<div class="d-flex align-items-center gap-3 mb-3">
|
||||||
|
<div class="d-flex align-items-center justify-content-center rounded-3" style="width:52px;height:52px;background:#fef2f2;">
|
||||||
|
<i class="bi bi-file-earmark-pdf" style="font-size:1.6rem;color:#ef4444;"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ doc.filename }}</div>
|
||||||
|
<small class="text-muted">Caricato il {{ doc.data_caricamento|date:"d/m/Y \a\l\l\e H:i" }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ doc.file.url }}" target="_blank" class="btn btn-primary mb-3" hx-boost="false">
|
||||||
|
<i class="bi bi-download me-2"></i>Scarica PDF
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if doc.descrizione %}
|
||||||
|
<hr class="soft my-3">
|
||||||
|
<p class="mb-0" style="white-space:pre-wrap;line-height:1.7;">{{ doc.descrizione }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr class="soft my-3">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<small class="text-muted d-block mb-1">Caricato da</small>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="avatar" style="width:22px;height:22px;font-size:.58rem;">{{ doc.caricato_da.username|slice:":2"|upper }}</span>
|
||||||
|
<span class="small">{{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if doc.conversazione %}
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<small class="text-muted d-block mb-1">Conversazione</small>
|
||||||
|
<a href="{% url 'conversazione_dettaglio' doc.conversazione.pk %}" class="small text-decoration-none" style="color:var(--accent);">
|
||||||
|
{{ doc.conversazione.titolo }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if doc.obiettivo %}
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<small class="text-muted d-block mb-1">Obiettivo</small>
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' doc.obiettivo.pk %}" class="small text-decoration-none" style="color:var(--accent);">
|
||||||
|
{{ doc.obiettivo.titolo }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
53
templates/diario/documenti/form.html
Normal file
53
templates/diario/documenti/form.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% block title %}{{ titolo_pagina }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8 fade-in">
|
||||||
|
<div class="d-flex align-items-center mb-4">
|
||||||
|
<a href="{% url 'documenti_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" enctype="multipart/form-data">
|
||||||
|
{% 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.descrizione.label }}</label>
|
||||||
|
{{ form.descrizione }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">{{ form.file.label }}</label>
|
||||||
|
{{ form.file }}
|
||||||
|
<div class="form-text">Solo file PDF, massimo 10 MB.</div>
|
||||||
|
{% if form.file.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ form.file.errors.0 }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label class="form-label fw-semibold">{{ form.conversazione.label }}</label>
|
||||||
|
{{ form.conversazione }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label class="form-label fw-semibold">{{ form.obiettivo.label }}</label>
|
||||||
|
{{ form.obiettivo }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-upload me-1"></i>Carica
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'documenti_lista' %}" class="btn btn-outline-secondary">Annulla</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
65
templates/diario/documenti/lista.html
Normal file
65
templates/diario/documenti/lista.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% block title %}Documenti{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4 fade-in">
|
||||||
|
<p class="section-title mb-0">Documenti</p>
|
||||||
|
<a href="{% url 'documento_nuovo' %}" class="btn btn-primary btn-sm px-3">
|
||||||
|
<i class="bi bi-upload me-1"></i>Carica documento
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for doc in documenti %}
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 fade-in">
|
||||||
|
<div class="card h-100 p-3">
|
||||||
|
<div class="d-flex align-items-start gap-3">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="d-flex align-items-center justify-content-center rounded-3" style="width:42px;height:42px;background:#fef2f2;">
|
||||||
|
<i class="bi bi-file-earmark-pdf" style="font-size:1.3rem;color:#ef4444;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 min-w-0">
|
||||||
|
<a href="{% url 'documento_dettaglio' doc.pk %}" class="text-decoration-none text-dark fw-semibold d-block text-truncate">
|
||||||
|
{{ doc.titolo }}
|
||||||
|
</a>
|
||||||
|
{% if doc.descrizione %}
|
||||||
|
<p class="text-muted small mb-1 mt-1">{{ doc.descrizione|truncatewords:15 }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap mt-2">
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<span class="avatar" style="width:18px;height:18px;font-size:.5rem;">{{ doc.caricato_da.username|slice:":2"|upper }}</span>
|
||||||
|
<small class="text-muted">{{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }}</small>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ doc.data_caricamento|date:"d/m/Y" }}</small>
|
||||||
|
</div>
|
||||||
|
{% if doc.conversazione %}
|
||||||
|
<div class="mt-1">
|
||||||
|
<a href="{% url 'conversazione_dettaglio' doc.conversazione.pk %}" class="small text-decoration-none" style="color:var(--accent);">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>{{ doc.conversazione.titolo|truncatewords:5 }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% elif doc.obiettivo %}
|
||||||
|
<div class="mt-1">
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' doc.obiettivo.pk %}" class="small text-decoration-none" style="color:var(--accent);">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>{{ doc.obiettivo.titolo|truncatewords:5 }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-file-earmark-pdf"></i>
|
||||||
|
<p>Nessun documento caricato.</p>
|
||||||
|
<a href="{% url 'documento_nuovo' %}" class="btn btn-primary btn-sm px-3">
|
||||||
|
<i class="bi bi-upload me-1"></i>Carica il primo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
32
templates/diario/login.html
Normal file
32
templates/diario/login.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login – Olimpic Nastri</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<div class="container" style="max-width:420px; margin-top:100px;">
|
||||||
|
<div class="card shadow-sm p-4">
|
||||||
|
<h3 class="mb-4 text-center fw-bold">Olimpic Progetto Nastri</h3>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="alert alert-danger">Username o password errati.</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input type="text" name="username" class="form-control" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-dark w-100">Entra</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
174
templates/diario/obiettivi/dettaglio.html
Normal file
174
templates/diario/obiettivi/dettaglio.html
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% load custom_filters %}
|
||||||
|
{% block title %}{{ obj.titolo }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex align-items-center mb-4 gap-3 fade-in">
|
||||||
|
<a href="{% url 'obiettivi_lista' %}" class="btn btn-icon btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left" style="font-size:.85rem;"></i>
|
||||||
|
</a>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h5 class="mb-0 fw-bold">{{ obj.titolo }}</h5>
|
||||||
|
</div>
|
||||||
|
<span class="badge-stato stato-{{ obj.stato }}">{{ obj.get_stato_display }}</span>
|
||||||
|
{% if can_edit %}
|
||||||
|
<a href="{% url 'obiettivo_modifica' obj.pk %}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Modifica
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'obiettivo_elimina' obj.pk %}" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="bi bi-trash me-1"></i>Elimina
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card info -->
|
||||||
|
<div class="card p-4 mb-4 fade-in">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-6 col-sm-3">
|
||||||
|
<small class="text-muted d-block mb-1">Tipo</small>
|
||||||
|
<span class="pill-tipo pill-{{ obj.tipo }}">{{ obj.get_tipo_display }}</span>
|
||||||
|
</div>
|
||||||
|
{% if obj.assegnato_a.all %}
|
||||||
|
<div class="col-6 col-sm-3">
|
||||||
|
<small class="text-muted d-block mb-1">Assegnato a</small>
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
{% for p in obj.assegnato_a.all %}
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<span class="avatar" style="width:22px;height:22px;font-size:.58rem;">{{ p.username|slice:":2"|upper }}</span>
|
||||||
|
<span class="small fw-semibold">{{ p.get_full_name|default:p.username }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if obj.data_scadenza %}
|
||||||
|
<div class="col-6 col-sm-3">
|
||||||
|
<small class="text-muted d-block mb-1">Scadenza</small>
|
||||||
|
<strong class="small">{{ obj.data_scadenza|date:"d/m/Y" }}</strong>
|
||||||
|
{% with days=obj.giorni_rimanenti %}
|
||||||
|
{% if days is not None %}
|
||||||
|
<div class="mt-1">
|
||||||
|
{% if days < 0 %}
|
||||||
|
<span class="countdown countdown-urgent"><i class="bi bi-exclamation-triangle-fill me-1"></i>Scaduto da {{ days|abs_val }} gg</span>
|
||||||
|
{% elif days == 0 %}
|
||||||
|
<span class="countdown countdown-urgent">Scade oggi!</span>
|
||||||
|
{% elif days == 1 %}
|
||||||
|
<span class="countdown countdown-urgent">Scade domani</span>
|
||||||
|
{% elif days <= 7 %}
|
||||||
|
<span class="countdown countdown-soon">{{ days }} giorni rimasti</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="countdown countdown-ok">{{ days }} giorni rimasti</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-6 col-sm-3">
|
||||||
|
<small class="text-muted d-block mb-1">Creato da</small>
|
||||||
|
<span class="small">{{ obj.creato_da.get_full_name|default:obj.creato_da.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if obj.descrizione %}
|
||||||
|
<hr class="soft my-3">
|
||||||
|
<p class="mb-0" style="white-space:pre-wrap;line-height:1.7;">{{ obj.descrizione }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Slider avanzamento AJAX -->
|
||||||
|
<hr class="soft my-3">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<small class="fw-semibold text-muted">Avanzamento</small>
|
||||||
|
<span class="slider-label" id="lbl-{{ obj.pk }}">{{ obj.avanzamento }}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="100" step="5"
|
||||||
|
value="{{ obj.avanzamento }}"
|
||||||
|
class="progress-slider w-100"
|
||||||
|
style="--val: {{ obj.avanzamento }}%;"
|
||||||
|
data-url="{% url 'obiettivo_avanzamento_ajax' obj.pk %}"
|
||||||
|
data-id="{{ obj.pk }}"
|
||||||
|
>
|
||||||
|
<span class="slider-saving d-none" id="saving-{{ obj.pk }}">salvataggio…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documenti allegati -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 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' %}?obiettivo={{ obj.pk }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-upload me-1"></i>Allega PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for doc in documenti %}
|
||||||
|
<div class="card mb-2 p-3 fade-in">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-file-earmark-pdf" style="font-size:1.5rem;color:#dc3545;"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<a href="{% url 'documento_dettaglio' doc.pk %}" class="text-decoration-none text-dark fw-semibold small d-block">
|
||||||
|
{{ doc.titolo }}
|
||||||
|
</a>
|
||||||
|
<small class="text-muted">{{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }} · {{ doc.data_caricamento|date:"d/m/Y" }}</small>
|
||||||
|
</div>
|
||||||
|
<a href="{{ doc.file.url }}" class="btn btn-sm btn-outline-secondary" hx-boost="false" target="_blank">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted small text-center py-2 fade-in">Nessun documento allegato.</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Aggiornamenti -->
|
||||||
|
<p class="section-title mt-4 fade-in">Aggiornamenti ({{ aggiornamenti.count }})</p>
|
||||||
|
|
||||||
|
<!-- Form nuovo aggiornamento -->
|
||||||
|
<div class="card p-3 mb-4 fade-in" style="border-left: 3px solid var(--accent) !important;">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label class="form-label fw-semibold small mb-2">Aggiungi un aggiornamento</label>
|
||||||
|
{{ agg_form.testo }}
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm mt-2 px-3">
|
||||||
|
<i class="bi bi-send me-1"></i>Pubblica
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for agg in aggiornamenti %}
|
||||||
|
<div class="card mb-2 p-3 fade-in">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="avatar" style="width:28px;height:28px;font-size:.65rem;">{{ agg.autore.username|slice:":2"|upper }}</span>
|
||||||
|
<span class="fw-semibold small">{{ agg.autore.get_full_name|default:agg.autore.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<small class="text-muted">{{ agg.data|date:"d/m/Y H:i" }}</small>
|
||||||
|
{% if user == agg.autore or user.is_superuser %}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-icon btn-outline-secondary" style="width:24px;height:24px;" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-three-dots" style="font-size:.7rem;"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end shadow border-0" style="border-radius:10px; min-width:140px;">
|
||||||
|
<li><a class="dropdown-item small" href="{% url 'aggiornamento_modifica' agg.pk %}"><i class="bi bi-pencil me-2 text-muted"></i>Modifica</a></li>
|
||||||
|
<li><a class="dropdown-item small text-danger" href="{% url 'aggiornamento_elimina' agg.pk %}"><i class="bi bi-trash me-2"></i>Elimina</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted small text-center py-3">Nessun aggiornamento ancora.</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
67
templates/diario/obiettivi/form.html
Normal file
67
templates/diario/obiettivi/form.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{% 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 'obiettivi_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.descrizione.label }}</label>
|
||||||
|
{{ form.descrizione }}
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label fw-semibold">{{ form.tipo.label }}</label>
|
||||||
|
{{ form.tipo }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label fw-semibold">{{ form.stato.label }}</label>
|
||||||
|
{{ form.stato }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label fw-semibold">{{ form.data_scadenza.label }}</label>
|
||||||
|
{{ form.data_scadenza }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-semibold">{{ form.assegnato_a.label }}</label>
|
||||||
|
<div class="form-text mb-2">Seleziona una o più persone (solo per obiettivi individuali).</div>
|
||||||
|
<div class="row row-cols-2 row-cols-sm-3 g-2">
|
||||||
|
{% for checkbox in form.assegnato_a %}
|
||||||
|
<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>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Salva</button>
|
||||||
|
{% if obj %}
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="btn btn-outline-secondary">Annulla</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'obiettivi_lista' %}" class="btn btn-outline-secondary">Annulla</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
101
templates/diario/obiettivi/lista.html
Normal file
101
templates/diario/obiettivi/lista.html
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% load custom_filters %}
|
||||||
|
{% block title %}Obiettivi{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4 fade-in">
|
||||||
|
<div>
|
||||||
|
<p class="section-title mb-0">Obiettivi del progetto</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'obiettivo_nuovo' %}" class="btn btn-primary btn-sm px-3">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Nuovo obiettivo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtri -->
|
||||||
|
<div class="mb-4 d-flex gap-2 flex-wrap fade-in">
|
||||||
|
<a href="?filtro=tutti" class="btn btn-sm {% if filtro == 'tutti' %}btn-primary{% else %}btn-outline-secondary{% endif %}">Tutti</a>
|
||||||
|
<a href="?filtro=collettivi" class="btn btn-sm {% if filtro == 'collettivi' %}btn-primary{% else %}btn-outline-secondary{% endif %}">Collettivi</a>
|
||||||
|
<a href="?filtro=individuali" class="btn btn-sm {% if filtro == 'individuali' %}btn-primary{% else %}btn-outline-secondary{% endif %}">Individuali</a>
|
||||||
|
<a href="?filtro=miei" class="btn btn-sm {% if filtro == 'miei' %}btn-primary{% else %}btn-outline-secondary{% endif %}">I miei</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for obj in obiettivi %}
|
||||||
|
<div class="col-12 col-md-6 col-xl-4">
|
||||||
|
<div class="card h-100 p-3 fade-in">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<span class="badge-stato stato-{{ obj.stato }}">{{ obj.get_stato_display }}</span>
|
||||||
|
<span class="pill-tipo pill-{{ obj.tipo }}">{{ obj.get_tipo_display }}</span>
|
||||||
|
</div>
|
||||||
|
{% if user == obj.creato_da or user.is_superuser %}
|
||||||
|
<a href="{% url 'obiettivo_modifica' obj.pk %}" class="btn btn-icon btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil" style="font-size:.75rem;"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-dark fw-semibold mb-1 d-block">
|
||||||
|
{{ obj.titolo }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if obj.descrizione %}
|
||||||
|
<p class="text-muted small mb-2">{{ obj.descrizione|truncatewords:18 }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if obj.assegnato_a.all %}
|
||||||
|
<div class="d-flex align-items-center gap-1 flex-wrap mb-2">
|
||||||
|
{% for p in obj.assegnato_a.all %}
|
||||||
|
<span class="avatar" style="width:22px;height:22px;font-size:.58rem;" title="{{ p.get_full_name|default:p.username }}">{{ p.username|slice:":2"|upper }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
<small class="text-muted ms-1">{% for p in obj.assegnato_a.all %}{{ p.get_full_name|default:p.username }}{% if not forloop.last %}, {% endif %}{% endfor %}</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Progress bar + slider AJAX -->
|
||||||
|
<div class="mt-auto pt-2">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="100" step="5"
|
||||||
|
value="{{ obj.avanzamento }}"
|
||||||
|
class="progress-slider flex-grow-1"
|
||||||
|
style="--val: {{ obj.avanzamento }}%;"
|
||||||
|
data-url="{% url 'obiettivo_avanzamento_ajax' obj.pk %}"
|
||||||
|
data-id="{{ obj.pk }}"
|
||||||
|
>
|
||||||
|
<span class="slider-label" id="lbl-{{ obj.pk }}">{{ obj.avanzamento }}%</span>
|
||||||
|
</div>
|
||||||
|
<span class="slider-saving d-none" id="saving-{{ obj.pk }}">salvataggio…</span>
|
||||||
|
{% if obj.data_scadenza %}
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-1">
|
||||||
|
<small class="text-muted"><i class="bi bi-calendar3 me-1"></i>{{ obj.data_scadenza|date:"d/m/Y" }}</small>
|
||||||
|
{% with days=obj.giorni_rimanenti %}
|
||||||
|
{% if days is not None %}
|
||||||
|
{% if days < 0 %}
|
||||||
|
<span class="countdown countdown-urgent" style="font-size:.65rem;">Scaduto</span>
|
||||||
|
{% elif days <= 3 %}
|
||||||
|
<span class="countdown countdown-urgent" style="font-size:.65rem;">{{ days }}gg</span>
|
||||||
|
{% elif days <= 7 %}
|
||||||
|
<span class="countdown countdown-soon" style="font-size:.65rem;">{{ days }}gg</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="countdown countdown-ok" style="font-size:.65rem;">{{ days }}gg</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-bullseye"></i>
|
||||||
|
<p>Nessun obiettivo trovato per questo filtro.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
104
templates/diario/persone/dettaglio.html
Normal file
104
templates/diario/persone/dettaglio.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% block title %}{{ persona.get_full_name|default:persona.username }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-4 gap-3 fade-in">
|
||||||
|
<a href="{% url 'persone_lista' %}" class="btn btn-icon btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left" style="font-size:.85rem;"></i>
|
||||||
|
</a>
|
||||||
|
<span class="avatar avatar-lg">{{ persona.username|slice:":2"|upper }}</span>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0 fw-bold">{{ persona.get_full_name|default:persona.username }}</h5>
|
||||||
|
<small class="text-muted">@{{ persona.username }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<ul class="nav nav-pills mb-4 gap-1 fade-in" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link active btn-sm" data-bs-toggle="pill" data-bs-target="#tab-conv">
|
||||||
|
<i class="bi bi-chat-quote me-1"></i>Conversazioni <span class="badge bg-secondary bg-opacity-25 text-dark ms-1">{{ conversazioni|length }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link btn-sm" data-bs-toggle="pill" data-bs-target="#tab-obj">
|
||||||
|
<i class="bi bi-bullseye me-1"></i>Obiettivi <span class="badge bg-secondary bg-opacity-25 text-dark ms-1">{{ obiettivi|length }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link btn-sm" data-bs-toggle="pill" data-bs-target="#tab-doc">
|
||||||
|
<i class="bi bi-file-pdf me-1"></i>Documenti <span class="badge bg-secondary bg-opacity-25 text-dark ms-1">{{ documenti|length }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Conversazioni -->
|
||||||
|
<div class="tab-pane fade show active" id="tab-conv">
|
||||||
|
{% for conv in conversazioni %}
|
||||||
|
<div class="card mb-2 p-3 fade-in">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'conversazione_dettaglio' conv.pk %}" class="text-decoration-none text-dark fw-semibold">{{ conv.titolo }}</a>
|
||||||
|
<div class="text-muted small mt-1">{{ conv.data|date:"d/m/Y H:i" }}</div>
|
||||||
|
</div>
|
||||||
|
{% if conv.registrato_da == persona %}
|
||||||
|
<span class="doc-badge">Autore</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-stato stato-aperto">Partecipante</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty-state"><i class="bi bi-chat-square-dots"></i><p>Nessuna conversazione.</p></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Obiettivi -->
|
||||||
|
<div class="tab-pane fade" id="tab-obj">
|
||||||
|
{% for obj in obiettivi %}
|
||||||
|
<div class="card mb-2 p-3 fade-in">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-dark fw-semibold">{{ obj.titolo }}</a>
|
||||||
|
<div class="d-flex gap-2 mt-1">
|
||||||
|
<span class="badge-stato stato-{{ obj.stato }}">{{ obj.get_stato_display }}</span>
|
||||||
|
<span class="pill-tipo pill-{{ obj.tipo }}">{{ obj.get_tipo_display }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if obj.creato_da == persona %}
|
||||||
|
<span class="doc-badge">Creato</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-stato stato-in_corso">Assegnato</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty-state"><i class="bi bi-bullseye"></i><p>Nessun obiettivo.</p></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documenti -->
|
||||||
|
<div class="tab-pane fade" id="tab-doc">
|
||||||
|
{% for doc in documenti %}
|
||||||
|
<div class="card mb-2 p-3 fade-in">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-file-earmark-pdf" style="font-size:1.2rem;color:#ef4444;"></i>
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'documento_dettaglio' doc.pk %}" class="text-decoration-none text-dark fw-semibold">{{ doc.titolo }}</a>
|
||||||
|
<div class="text-muted small">{{ doc.data_caricamento|date:"d/m/Y" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty-state"><i class="bi bi-file-earmark-pdf"></i><p>Nessun documento.</p></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
33
templates/diario/persone/lista.html
Normal file
33
templates/diario/persone/lista.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% block title %}Persone{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="section-title fade-in">Team</p>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for persona in persone %}
|
||||||
|
<div class="col-6 col-md-4 col-lg-3 fade-in">
|
||||||
|
<a href="{% url 'persona_dettaglio' persona.pk %}" class="card-link">
|
||||||
|
<div class="card person-card h-100">
|
||||||
|
<span class="avatar avatar-lg mx-auto">{{ persona.username|slice:":2"|upper }}</span>
|
||||||
|
<div class="fw-semibold small">{{ persona.get_full_name|default:persona.username }}</div>
|
||||||
|
<div class="mt-2 d-flex justify-content-center gap-3">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num">{{ persona.num_conversazioni }}</div>
|
||||||
|
<i class="bi bi-chat-quote"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num">{{ persona.num_obiettivi }}</div>
|
||||||
|
<i class="bi bi-bullseye"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num">{{ persona.num_documenti }}</div>
|
||||||
|
<i class="bi bi-file-pdf"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
98
templates/diario/ricerca.html
Normal file
98
templates/diario/ricerca.html
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{% extends "diario/base.html" %}
|
||||||
|
{% block title %}Ricerca{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8 fade-in">
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-4">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-search me-2" style="color:var(--accent);"></i>Ricerca</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" class="mb-4">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="search" name="q" value="{{ q }}" class="form-control form-control-lg" placeholder="Cerca conversazioni, obiettivi, documenti, persone..." autofocus>
|
||||||
|
<button type="submit" class="btn btn-primary px-4">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if q %}
|
||||||
|
<p class="text-muted small mb-4">
|
||||||
|
{% if total %}{{ total }} risultat{{ total|pluralize:"o,i" }} per <strong>"{{ q }}"</strong>{% else %}Nessun risultato per <strong>"{{ q }}"</strong>{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if risultati.persone %}
|
||||||
|
<p class="section-title"><i class="bi bi-people me-1"></i>Persone</p>
|
||||||
|
<div class="row g-2 mb-4">
|
||||||
|
{% for persona in risultati.persone %}
|
||||||
|
<div class="col-6 col-md-4 fade-in">
|
||||||
|
<a href="{% url 'persona_dettaglio' persona.pk %}" class="card-link">
|
||||||
|
<div class="card p-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="avatar" style="width:24px;height:24px;font-size:.6rem;">{{ persona.username|slice:":2"|upper }}</span>
|
||||||
|
<span class="fw-semibold small">{{ persona.get_full_name|default:persona.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if risultati.conversazioni %}
|
||||||
|
<p class="section-title"><i class="bi bi-chat-quote me-1"></i>Conversazioni</p>
|
||||||
|
{% for conv in risultati.conversazioni %}
|
||||||
|
<div class="card mb-2 p-3 fade-in">
|
||||||
|
<a href="{% url 'conversazione_dettaglio' conv.pk %}" class="text-decoration-none text-dark fw-semibold">{{ conv.titolo }}</a>
|
||||||
|
<div class="d-flex gap-2 mt-1">
|
||||||
|
<small class="text-muted">{{ conv.data|date:"d/m/Y" }}</small>
|
||||||
|
{% if conv.registrato_da %}
|
||||||
|
<small class="text-muted">· {{ conv.registrato_da.get_full_name|default:conv.registrato_da.username }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0 mt-1">{{ conv.contenuto|truncatewords:25 }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="mb-4"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if risultati.obiettivi %}
|
||||||
|
<p class="section-title"><i class="bi bi-bullseye me-1"></i>Obiettivi</p>
|
||||||
|
{% for obj in risultati.obiettivi %}
|
||||||
|
<div class="card mb-2 p-3 fade-in">
|
||||||
|
<div class="d-flex align-items-start justify-content-between">
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-dark fw-semibold">{{ obj.titolo }}</a>
|
||||||
|
{% if obj.descrizione %}
|
||||||
|
<p class="text-muted small mb-0 mt-1">{{ obj.descrizione|truncatewords:20 }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="badge-stato stato-{{ obj.stato }} ms-2">{{ obj.get_stato_display }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="mb-4"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if risultati.documenti %}
|
||||||
|
<p class="section-title"><i class="bi bi-file-earmark-pdf me-1"></i>Documenti</p>
|
||||||
|
{% for doc in risultati.documenti %}
|
||||||
|
<div class="card mb-2 p-3 fade-in">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-file-earmark-pdf" style="color:#ef4444;"></i>
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'documento_dettaglio' doc.pk %}" class="text-decoration-none text-dark fw-semibold">{{ doc.titolo }}</a>
|
||||||
|
{% if doc.descrizione %}<p class="text-muted small mb-0 mt-1">{{ doc.descrizione|truncatewords:15 }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
40
templates/diario/ricerca_risultati.html
Normal file
40
templates/diario/ricerca_risultati.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{% if total == 0 and q %}
|
||||||
|
<div class="p-3 text-center text-muted small">Nessun risultato per "{{ q }}"</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for persona in risultati.persone %}
|
||||||
|
<a href="{% url 'persona_dettaglio' persona.pk %}" class="result-item">
|
||||||
|
<div class="result-type">Persona</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="avatar" style="width:20px;height:20px;font-size:.55rem;">{{ persona.username|slice:":2"|upper }}</span>
|
||||||
|
<span class="fw-semibold small">{{ persona.get_full_name|default:persona.username }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for conv in risultati.conversazioni|slice:":5" %}
|
||||||
|
<a href="{% url 'conversazione_dettaglio' conv.pk %}" class="result-item">
|
||||||
|
<div class="result-type">Conversazione</div>
|
||||||
|
<span class="small">{{ conv.titolo }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for obj in risultati.obiettivi|slice:":5" %}
|
||||||
|
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="result-item">
|
||||||
|
<div class="result-type">Obiettivo</div>
|
||||||
|
<span class="small">{{ obj.titolo }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for doc in risultati.documenti|slice:":5" %}
|
||||||
|
<a href="{% url 'documento_dettaglio' doc.pk %}" class="result-item">
|
||||||
|
<div class="result-type">Documento</div>
|
||||||
|
<span class="small">{{ doc.titolo }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if total > 0 %}
|
||||||
|
<a href="{% url 'ricerca' %}?q={{ q|urlencode }}" class="result-item text-center" style="color:var(--accent);font-weight:600;font-size:.8rem;">
|
||||||
|
Vedi tutti i {{ total }} risultati →
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
Reference in New Issue
Block a user