Fascicoli+Tassonomia+permessi
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Migrazione 0016: tabelle fascicoli e fascicolo_messages.
|
||||
Fascicolazione / Dossier — raggruppa piu' comunicazioni PEC in una pratica.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0016"
|
||||
down_revision = "0015"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ─── Tabella fascicoli ─────────────────────────────────────────────────────
|
||||
# L'ENUM fascicolo_stato viene creato automaticamente da SQLAlchemy
|
||||
# come parte di op.create_table (no op.execute separato)
|
||||
op.create_table(
|
||||
"fascicoli",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("titolo", sa.String(255), nullable=False),
|
||||
sa.Column("numero_pratica", sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
"stato",
|
||||
sa.Enum("aperto", "chiuso", "archiviato", name="fascicolo_stato"),
|
||||
nullable=False,
|
||||
server_default="aperto",
|
||||
),
|
||||
sa.Column("categoria", sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
"responsabile_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("scadenza", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("note", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("idx_fascicoli_tenant", "fascicoli", ["tenant_id"])
|
||||
op.create_index("idx_fascicoli_stato", "fascicoli", ["stato"])
|
||||
op.create_index("idx_fascicoli_responsabile", "fascicoli", ["responsabile_id"])
|
||||
|
||||
# ─── Tabella fascicolo_messages (N:M) ──────────────────────────────────────
|
||||
op.create_table(
|
||||
"fascicolo_messages",
|
||||
sa.Column(
|
||||
"fascicolo_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("fascicoli.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"message_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("messages.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"added_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"added_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_fascicolo_messages_fascicolo", "fascicolo_messages", ["fascicolo_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"idx_fascicolo_messages_message", "fascicolo_messages", ["message_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_fascicolo_messages_message", table_name="fascicolo_messages")
|
||||
op.drop_index("idx_fascicolo_messages_fascicolo", table_name="fascicolo_messages")
|
||||
op.drop_table("fascicolo_messages")
|
||||
|
||||
op.drop_index("idx_fascicoli_responsabile", table_name="fascicoli")
|
||||
op.drop_index("idx_fascicoli_stato", table_name="fascicoli")
|
||||
op.drop_index("idx_fascicoli_tenant", table_name="fascicoli")
|
||||
op.drop_table("fascicoli")
|
||||
|
||||
op.execute("DROP TYPE fascicolo_stato")
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Migrazione 0017: Tassonomia di Classificazione Multi-livello (Feature N2).
|
||||
|
||||
Estende la tabella `labels` con:
|
||||
- parent_id: FK self-referenziale (nullable) per struttura ad albero
|
||||
- description: testo descrittivo opzionale
|
||||
|
||||
Struttura tassonomica:
|
||||
Livello 0 (radice) = Ambito (Area Aziendale) — parent_id IS NULL
|
||||
Livello 1 = Processo — parent_id = ID Ambito
|
||||
Livello 2 (foglia) = Classificazione — parent_id = ID Processo
|
||||
|
||||
I vincoli di unicità vengono sostituiti con indici parziali per supportare
|
||||
nomi identici a diversi livelli dell'albero.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0017"
|
||||
down_revision = "0016"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Aggiunge parent_id (FK self-referenziale nullable) ─────────────────────
|
||||
op.add_column(
|
||||
"labels",
|
||||
sa.Column(
|
||||
"parent_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_labels_parent",
|
||||
"labels",
|
||||
"labels",
|
||||
["parent_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
# ── Aggiunge description ────────────────────────────────────────────────────
|
||||
op.add_column(
|
||||
"labels",
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
# ── Indice su parent_id per query gerarchiche ───────────────────────────────
|
||||
op.create_index("idx_labels_parent", "labels", ["parent_id"])
|
||||
|
||||
# ── Sostituisce il vincolo di unicità con indici parziali ───────────────────
|
||||
# Il vecchio vincolo (tenant_id, name) non supporta nomi uguali a livelli diversi
|
||||
op.drop_constraint("uq_label_name_tenant", "labels")
|
||||
|
||||
# Nodi radice: nome unico per tenant (parent_id IS NULL)
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX uq_label_name_root "
|
||||
"ON labels (tenant_id, name) WHERE parent_id IS NULL"
|
||||
)
|
||||
# Nodi non-radice: nome unico per (tenant, parent) — stesso nome ammesso sotto parent diversi
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX uq_label_name_parent "
|
||||
"ON labels (tenant_id, name, parent_id) WHERE parent_id IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS uq_label_name_parent")
|
||||
op.execute("DROP INDEX IF EXISTS uq_label_name_root")
|
||||
op.create_unique_constraint("uq_label_name_tenant", "labels", ["tenant_id", "name"])
|
||||
op.drop_index("idx_labels_parent", table_name="labels")
|
||||
op.drop_constraint("fk_labels_parent", "labels", type_="foreignkey")
|
||||
op.drop_column("labels", "description")
|
||||
op.drop_column("labels", "parent_id")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Migrazione 0018: Livello di Rischio e Riservatezza per Comunicazione (Feature N3).
|
||||
|
||||
Aggiunge alla tabella `messages`:
|
||||
- risk_level ENUM(low, medium, high, critical) nullable
|
||||
- confidentiality ENUM(public, internal, confidential, secret) nullable
|
||||
|
||||
Entrambi i campi sono nullable: rimangono NULL finche' non vengono
|
||||
impostati manualmente o da una regola di smistamento (set_risk_level /
|
||||
set_confidentiality).
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0018"
|
||||
down_revision = "0017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Crea i nuovi ENUM types ─────────────────────────────────────────────────
|
||||
risk_level_enum = postgresql.ENUM(
|
||||
"low", "medium", "high", "critical",
|
||||
name="risk_level",
|
||||
create_type=True,
|
||||
)
|
||||
confidentiality_enum = postgresql.ENUM(
|
||||
"public", "internal", "confidential", "secret",
|
||||
name="confidentiality_level",
|
||||
create_type=True,
|
||||
)
|
||||
risk_level_enum.create(op.get_bind(), checkfirst=True)
|
||||
confidentiality_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# ── Aggiunge le colonne a messages ──────────────────────────────────────────
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column(
|
||||
"risk_level",
|
||||
sa.Enum("low", "medium", "high", "critical", name="risk_level", create_type=False),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column(
|
||||
"confidentiality",
|
||||
sa.Enum("public", "internal", "confidential", "secret", name="confidentiality_level", create_type=False),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
# ── Indici per filtrare rapidamente per rischio/riservatezza ────────────────
|
||||
op.create_index(
|
||||
"idx_messages_risk_level",
|
||||
"messages",
|
||||
["tenant_id", "risk_level"],
|
||||
postgresql_where=sa.text("risk_level IS NOT NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_messages_confidentiality",
|
||||
"messages",
|
||||
["tenant_id", "confidentiality"],
|
||||
postgresql_where=sa.text("confidentiality IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_messages_confidentiality", table_name="messages")
|
||||
op.drop_index("idx_messages_risk_level", table_name="messages")
|
||||
op.drop_column("messages", "confidentiality")
|
||||
op.drop_column("messages", "risk_level")
|
||||
|
||||
risk_level_enum = postgresql.ENUM(name="risk_level")
|
||||
confidentiality_enum = postgresql.ENUM(name="confidentiality_level")
|
||||
risk_level_enum.drop(op.get_bind(), checkfirst=True)
|
||||
confidentiality_enum.drop(op.get_bind(), checkfirst=True)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Migrazione 0019: Tabella permission_presets (sottoruoli/template permessi).
|
||||
|
||||
Aggiunge la tabella `permission_presets` che consente ad admin e supervisor
|
||||
di definire template nominati di permessi riutilizzabili (es. "Operatore Archivio",
|
||||
"Operatore Invio", ecc.) da applicare agli operatori sulle caselle PEC.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0019"
|
||||
down_revision = "0018"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"permission_presets",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("can_read", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("can_send", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("can_manage", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("can_conserve", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
|
||||
# Unicita' nome per tenant
|
||||
op.create_unique_constraint(
|
||||
"uq_preset_name_tenant",
|
||||
"permission_presets",
|
||||
["tenant_id", "name"],
|
||||
)
|
||||
|
||||
# Indici
|
||||
op.create_index("idx_preset_tenant", "permission_presets", ["tenant_id"])
|
||||
op.create_index("idx_preset_created_by", "permission_presets", ["created_by"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_preset_created_by", table_name="permission_presets")
|
||||
op.drop_index("idx_preset_tenant", table_name="permission_presets")
|
||||
op.drop_constraint("uq_preset_name_tenant", "permission_presets", type_="unique")
|
||||
op.drop_table("permission_presets")
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Router Fascicoli — fascicolazione pratiche.
|
||||
|
||||
Endpoint:
|
||||
- GET /fascicoli – lista fascicoli (con filtri)
|
||||
- POST /fascicoli – crea un fascicolo
|
||||
- GET /fascicoli/{id} – dettaglio fascicolo
|
||||
- PATCH /fascicoli/{id} – modifica fascicolo
|
||||
- DELETE /fascicoli/{id} – elimina fascicolo (solo admin)
|
||||
|
||||
- GET /fascicoli/{id}/messages – messaggi del fascicolo
|
||||
- POST /fascicoli/{id}/messages – aggiungi messaggi al fascicolo
|
||||
- DELETE /fascicoli/{id}/messages – rimuovi messaggi dal fascicolo
|
||||
|
||||
- GET /messages/{message_id}/fascicoli – fascicoli di un messaggio
|
||||
|
||||
Permessi:
|
||||
- Tutti gli utenti autenticati possono creare fascicoli.
|
||||
- PATCH: creatore o admin.
|
||||
- DELETE fascicolo: solo admin.
|
||||
- Operazioni su messaggi: utente con accesso al tenant.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import ForbiddenError, NotFoundError
|
||||
from app.dependencies import AdminUser, CurrentUser, DB
|
||||
from app.models.message import Message
|
||||
from app.schemas.fascicolo import (
|
||||
FascicoloAddMessagesRequest,
|
||||
FascicoloCreate,
|
||||
FascicoloMessageItem,
|
||||
FascicoloRemoveMessagesRequest,
|
||||
FascicoloResponse,
|
||||
FascicoloUpdate,
|
||||
MessageFascicoloSummary,
|
||||
)
|
||||
from app.services.fascicolo_service import FascicoloService
|
||||
|
||||
router = APIRouter(tags=["Fascicoli"])
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _to_response(fascicolo, message_count: int) -> FascicoloResponse:
|
||||
resp = FascicoloResponse.model_validate(fascicolo)
|
||||
resp.message_count = int(message_count)
|
||||
return resp
|
||||
|
||||
|
||||
# ─── CRUD Fascicolo ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/fascicoli", response_model=list[FascicoloResponse])
|
||||
async def list_fascicoli(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
stato: Optional[str] = Query(None, pattern=r"^(aperto|chiuso|archiviato)$"),
|
||||
responsabile_id: Optional[uuid.UUID] = Query(None),
|
||||
search: Optional[str] = Query(None, max_length=200),
|
||||
) -> list[FascicoloResponse]:
|
||||
"""Elenca i fascicoli del tenant con filtri opzionali."""
|
||||
svc = FascicoloService(db)
|
||||
rows = await svc.list_fascicoli(
|
||||
current_user.tenant_id,
|
||||
stato=stato,
|
||||
responsabile_id=responsabile_id,
|
||||
search=search,
|
||||
)
|
||||
return [_to_response(f, cnt) for f, cnt in rows]
|
||||
|
||||
|
||||
@router.post("/fascicoli", response_model=FascicoloResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_fascicolo(
|
||||
data: FascicoloCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> FascicoloResponse:
|
||||
"""Crea un nuovo fascicolo."""
|
||||
svc = FascicoloService(db)
|
||||
fascicolo, cnt = await svc.create_fascicolo(
|
||||
current_user.tenant_id, data, created_by=current_user.id
|
||||
)
|
||||
return _to_response(fascicolo, cnt)
|
||||
|
||||
|
||||
@router.get("/fascicoli/{fascicolo_id}", response_model=FascicoloResponse)
|
||||
async def get_fascicolo(
|
||||
fascicolo_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> FascicoloResponse:
|
||||
"""Restituisce il dettaglio di un fascicolo."""
|
||||
svc = FascicoloService(db)
|
||||
fascicolo, cnt = await svc.get_fascicolo(current_user.tenant_id, fascicolo_id)
|
||||
return _to_response(fascicolo, cnt)
|
||||
|
||||
|
||||
@router.patch("/fascicoli/{fascicolo_id}", response_model=FascicoloResponse)
|
||||
async def update_fascicolo(
|
||||
fascicolo_id: uuid.UUID,
|
||||
data: FascicoloUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> FascicoloResponse:
|
||||
"""
|
||||
Modifica un fascicolo.
|
||||
Solo il creatore o un amministratore possono modificarlo.
|
||||
"""
|
||||
svc = FascicoloService(db)
|
||||
fascicolo, cnt = await svc.update_fascicolo(
|
||||
current_user.tenant_id,
|
||||
fascicolo_id,
|
||||
data,
|
||||
current_user_id=current_user.id,
|
||||
is_admin=current_user.is_admin,
|
||||
)
|
||||
return _to_response(fascicolo, cnt)
|
||||
|
||||
|
||||
@router.delete("/fascicoli/{fascicolo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_fascicolo(
|
||||
fascicolo_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
"""Elimina un fascicolo (solo admin). I messaggi non vengono eliminati."""
|
||||
svc = FascicoloService(db)
|
||||
await svc.delete_fascicolo(current_user.tenant_id, fascicolo_id)
|
||||
|
||||
|
||||
# ─── Messaggi del fascicolo ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/fascicoli/{fascicolo_id}/messages", response_model=list[FascicoloMessageItem])
|
||||
async def get_fascicolo_messages(
|
||||
fascicolo_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[FascicoloMessageItem]:
|
||||
"""Restituisce i messaggi collegati al fascicolo."""
|
||||
svc = FascicoloService(db)
|
||||
rows = await svc.get_fascicolo_messages(current_user.tenant_id, fascicolo_id)
|
||||
items = []
|
||||
for msg, added_at in rows:
|
||||
item = FascicoloMessageItem(
|
||||
id=msg.id,
|
||||
subject=msg.subject,
|
||||
from_address=msg.from_address,
|
||||
to_addresses=msg.to_addresses,
|
||||
direction=msg.direction,
|
||||
pec_type=msg.pec_type,
|
||||
state=msg.state,
|
||||
mailbox_id=msg.mailbox_id,
|
||||
received_at=msg.received_at,
|
||||
sent_at=msg.sent_at,
|
||||
created_at=msg.created_at,
|
||||
added_at=added_at,
|
||||
)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/fascicoli/{fascicolo_id}/messages", response_model=dict)
|
||||
async def add_messages_to_fascicolo(
|
||||
fascicolo_id: uuid.UUID,
|
||||
data: FascicoloAddMessagesRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> dict:
|
||||
"""
|
||||
Aggiunge messaggi al fascicolo.
|
||||
Ignora i messaggi non del tenant o gia' presenti.
|
||||
"""
|
||||
svc = FascicoloService(db)
|
||||
added = await svc.add_messages(
|
||||
current_user.tenant_id,
|
||||
fascicolo_id,
|
||||
data.message_ids,
|
||||
added_by=current_user.id,
|
||||
)
|
||||
return {"added": added}
|
||||
|
||||
|
||||
@router.delete("/fascicoli/{fascicolo_id}/messages", response_model=dict)
|
||||
async def remove_messages_from_fascicolo(
|
||||
fascicolo_id: uuid.UUID,
|
||||
data: FascicoloRemoveMessagesRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> dict:
|
||||
"""Rimuove messaggi dal fascicolo (non li elimina dalla posta)."""
|
||||
svc = FascicoloService(db)
|
||||
removed = await svc.remove_messages(
|
||||
current_user.tenant_id,
|
||||
fascicolo_id,
|
||||
data.message_ids,
|
||||
)
|
||||
return {"removed": removed}
|
||||
|
||||
|
||||
# ─── Fascicoli di un messaggio ────────────────────────────────────────────────
|
||||
|
||||
@router.get("/messages/{message_id}/fascicoli", response_model=list[MessageFascicoloSummary])
|
||||
async def get_message_fascicoli(
|
||||
message_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[MessageFascicoloSummary]:
|
||||
"""Restituisce i fascicoli a cui appartiene un messaggio."""
|
||||
# Verifica che il messaggio esista nel tenant
|
||||
result = await db.execute(
|
||||
select(Message).where(
|
||||
Message.id == message_id,
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise NotFoundError(f"Messaggio {message_id} non trovato")
|
||||
|
||||
svc = FascicoloService(db)
|
||||
fascicoli = await svc.get_message_fascicoli(current_user.tenant_id, message_id)
|
||||
return [MessageFascicoloSummary.model_validate(f) for f in fascicoli]
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
Router tag (Label).
|
||||
Router tag (Label) con supporto tassonomia gerarchica (Feature N2).
|
||||
|
||||
Endpoint:
|
||||
- GET /labels – elenca i tag del tenant
|
||||
- POST /labels – crea un nuovo tag (admin)
|
||||
- GET /labels – elenca i tag del tenant (flat)
|
||||
- GET /labels/tree – albero tassonomico (Ambito > Processo > Classificazione)
|
||||
- POST /labels – crea un nuovo tag / nodo tassonomico (admin)
|
||||
- PATCH /labels/{id} – modifica un tag (admin)
|
||||
- DELETE /labels/{id} – elimina un tag (admin)
|
||||
|
||||
@@ -31,6 +32,7 @@ from app.models.message import Message
|
||||
from app.schemas.label import (
|
||||
LabelCreate,
|
||||
LabelResponse,
|
||||
LabelTreeResponse,
|
||||
LabelUpdate,
|
||||
MessageBulkLabelRequest,
|
||||
MessageBulkLabelResponse,
|
||||
@@ -77,12 +79,28 @@ async def list_labels(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[LabelResponse]:
|
||||
"""Elenca tutti i tag del tenant corrente."""
|
||||
"""Elenca tutti i tag del tenant corrente (lista flat, include nodi tassonomici)."""
|
||||
svc = LabelService(db)
|
||||
labels = await svc.list_labels(current_user.tenant_id)
|
||||
return [LabelResponse.model_validate(l) for l in labels]
|
||||
|
||||
|
||||
@router.get("/labels/tree", response_model=list[LabelTreeResponse])
|
||||
async def get_label_tree(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[LabelTreeResponse]:
|
||||
"""
|
||||
Restituisce la tassonomia come albero annidato.
|
||||
|
||||
Struttura: [ Ambito { children: [ Processo { children: [ Classificazione ] } ] } ]
|
||||
|
||||
I nodi radice (parent_id=NULL) possono essere sia Ambiti tassonomici che label piatte.
|
||||
"""
|
||||
svc = LabelService(db)
|
||||
return await svc.get_label_tree(current_user.tenant_id)
|
||||
|
||||
|
||||
@router.post("/labels", response_model=LabelResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_label(
|
||||
data: LabelCreate,
|
||||
|
||||
@@ -595,6 +595,14 @@ async def update_message(
|
||||
elif not data.is_conserved:
|
||||
message.conserved_at = None
|
||||
|
||||
# Rischio e Riservatezza (Feature N3) — stringa vuota resetta a NULL
|
||||
_VALID_RISK = {"low", "medium", "high", "critical"}
|
||||
_VALID_CONF = {"public", "internal", "confidential", "secret"}
|
||||
if data.risk_level is not None:
|
||||
message.risk_level = data.risk_level if data.risk_level in _VALID_RISK else None
|
||||
if data.confidentiality is not None:
|
||||
message.confidentiality = data.confidentiality if data.confidentiality in _VALID_CONF else None
|
||||
|
||||
# Registra un evento di audit per ogni flag modificato
|
||||
for field, (action_true, action_false) in _FLAG_ACTIONS.items():
|
||||
value = getattr(data, field, None)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Router preset permessi (sottoruoli nominati).
|
||||
|
||||
Endpoint:
|
||||
GET /api/v1/permission-presets → lista preset del tenant
|
||||
POST /api/v1/permission-presets → crea preset
|
||||
GET /api/v1/permission-presets/{id} → dettaglio preset
|
||||
PUT /api/v1/permission-presets/{id} → aggiorna preset
|
||||
DELETE /api/v1/permission-presets/{id} → elimina preset
|
||||
|
||||
Accesso: admin e supervisor.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.dependencies import DB, SupervisorOrAdminUser
|
||||
from app.schemas.permission_preset import (
|
||||
PermissionPresetCreate,
|
||||
PermissionPresetResponse,
|
||||
PermissionPresetUpdate,
|
||||
)
|
||||
from app.services.permission_preset_service import PermissionPresetService
|
||||
|
||||
router = APIRouter(prefix="/permission-presets", tags=["Preset permessi"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[PermissionPresetResponse],
|
||||
summary="Lista preset permessi del tenant",
|
||||
)
|
||||
async def list_presets(
|
||||
current_user: SupervisorOrAdminUser,
|
||||
db: DB,
|
||||
) -> list[PermissionPresetResponse]:
|
||||
service = PermissionPresetService(db)
|
||||
presets = await service.list_presets(current_user.tenant_id)
|
||||
return [PermissionPresetResponse.model_validate(p) for p in presets]
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=PermissionPresetResponse,
|
||||
status_code=201,
|
||||
summary="Crea un nuovo preset di permessi",
|
||||
)
|
||||
async def create_preset(
|
||||
body: PermissionPresetCreate,
|
||||
current_user: SupervisorOrAdminUser,
|
||||
db: DB,
|
||||
) -> PermissionPresetResponse:
|
||||
service = PermissionPresetService(db)
|
||||
preset = await service.create_preset(
|
||||
tenant_id=current_user.tenant_id,
|
||||
data=body,
|
||||
created_by=current_user,
|
||||
)
|
||||
return PermissionPresetResponse.model_validate(preset)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{preset_id}",
|
||||
response_model=PermissionPresetResponse,
|
||||
summary="Dettaglio preset",
|
||||
)
|
||||
async def get_preset(
|
||||
preset_id: uuid.UUID,
|
||||
current_user: SupervisorOrAdminUser,
|
||||
db: DB,
|
||||
) -> PermissionPresetResponse:
|
||||
service = PermissionPresetService(db)
|
||||
preset = await service.get_preset(preset_id, current_user.tenant_id)
|
||||
return PermissionPresetResponse.model_validate(preset)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{preset_id}",
|
||||
response_model=PermissionPresetResponse,
|
||||
summary="Aggiorna un preset",
|
||||
)
|
||||
async def update_preset(
|
||||
preset_id: uuid.UUID,
|
||||
body: PermissionPresetUpdate,
|
||||
current_user: SupervisorOrAdminUser,
|
||||
db: DB,
|
||||
) -> PermissionPresetResponse:
|
||||
service = PermissionPresetService(db)
|
||||
preset = await service.update_preset(
|
||||
preset_id=preset_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
data=body,
|
||||
updated_by=current_user,
|
||||
)
|
||||
return PermissionPresetResponse.model_validate(preset)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{preset_id}",
|
||||
status_code=204,
|
||||
summary="Elimina un preset",
|
||||
)
|
||||
async def delete_preset(
|
||||
preset_id: uuid.UUID,
|
||||
current_user: SupervisorOrAdminUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
service = PermissionPresetService(db)
|
||||
await service.delete_preset(
|
||||
preset_id=preset_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
deleted_by=current_user,
|
||||
)
|
||||
@@ -12,7 +12,7 @@ import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.dependencies import AdminUser, CurrentUser, DB
|
||||
from app.dependencies import AdminUser, CurrentUser, DB, SupervisorOrAdminUser
|
||||
from app.schemas.permission import (
|
||||
MailboxUserPermissionResponse,
|
||||
PermissionGrantRequest,
|
||||
@@ -35,7 +35,7 @@ async def grant_permission(
|
||||
mailbox_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
body: PermissionGrantRequest,
|
||||
current_user: AdminUser,
|
||||
current_user: SupervisorOrAdminUser,
|
||||
db: DB,
|
||||
) -> PermissionResponse:
|
||||
service = PermissionService(db)
|
||||
@@ -57,7 +57,7 @@ async def grant_permission(
|
||||
async def revoke_permission(
|
||||
mailbox_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
current_user: SupervisorOrAdminUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
service = PermissionService(db)
|
||||
@@ -75,7 +75,7 @@ async def revoke_permission(
|
||||
)
|
||||
async def list_mailbox_users(
|
||||
mailbox_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
current_user: SupervisorOrAdminUser,
|
||||
db: DB,
|
||||
) -> list[MailboxUserPermissionResponse]:
|
||||
service = PermissionService(db)
|
||||
@@ -90,7 +90,7 @@ async def list_mailbox_users(
|
||||
)
|
||||
async def list_user_mailboxes(
|
||||
user_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
current_user: SupervisorOrAdminUser,
|
||||
db: DB,
|
||||
) -> list[UserMailboxPermissionResponse]:
|
||||
service = PermissionService(db)
|
||||
|
||||
+3
-1
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, signatures, tenants, templates, users, virtual_boxes, ws
|
||||
from app.api.v1 import audit_log, auth, contacts, deadlines, fascicoli, labels, mailboxes, messages, notifications, permission_presets, permissions, reports, routing_rules, send, signatures, tenants, templates, users, virtual_boxes, ws
|
||||
from app.api.v1 import settings as settings_router
|
||||
from app.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
@@ -88,6 +88,7 @@ app.include_router(auth.router, prefix=API_PREFIX)
|
||||
app.include_router(users.router, prefix=API_PREFIX)
|
||||
app.include_router(tenants.router, prefix=API_PREFIX)
|
||||
app.include_router(permissions.router, prefix=API_PREFIX)
|
||||
app.include_router(permission_presets.router, prefix=API_PREFIX)
|
||||
app.include_router(mailboxes.router, prefix=API_PREFIX)
|
||||
app.include_router(messages.router, prefix=API_PREFIX)
|
||||
app.include_router(send.router, prefix=API_PREFIX)
|
||||
@@ -103,6 +104,7 @@ app.include_router(routing_rules.router, prefix=API_PREFIX)
|
||||
app.include_router(contacts.router, prefix=API_PREFIX)
|
||||
app.include_router(deadlines.router, prefix=API_PREFIX)
|
||||
app.include_router(signatures.router, prefix=API_PREFIX)
|
||||
app.include_router(fascicoli.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
# ─── Health check ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,3 +14,4 @@ from app.models.template import MessageTemplate # noqa: F401
|
||||
from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # noqa: F401
|
||||
from app.models.pec_contact import PecContact # noqa: F401
|
||||
from app.models.signature import Signature, SignatureAssignment # noqa: F401
|
||||
from app.models.fascicolo import Fascicolo, FascicoloMessage # noqa: F401
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Modelli Fascicolo e FascicoloMessage — fascicolazione pratiche.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Index, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
FascicoloStato = Enum(
|
||||
"aperto", "chiuso", "archiviato",
|
||||
name="fascicolo_stato",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
class Fascicolo(Base):
|
||||
__tablename__ = "fascicoli"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
titolo: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
numero_pratica: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
stato: Mapped[str] = mapped_column(FascicoloStato, nullable=False, default="aperto")
|
||||
categoria: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
responsabile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
scadenza: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
fascicolo_messages: Mapped[list["FascicoloMessage"]] = relationship(
|
||||
"FascicoloMessage", back_populates="fascicolo", cascade="all, delete-orphan"
|
||||
)
|
||||
messages: Mapped[list] = relationship(
|
||||
"Message",
|
||||
secondary="fascicolo_messages",
|
||||
lazy="select",
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_fascicoli_tenant", "tenant_id"),
|
||||
Index("idx_fascicoli_stato", "stato"),
|
||||
Index("idx_fascicoli_responsabile", "responsabile_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Fascicolo {self.id} {self.titolo!r} {self.stato!r}>"
|
||||
|
||||
|
||||
class FascicoloMessage(Base):
|
||||
__tablename__ = "fascicolo_messages"
|
||||
|
||||
fascicolo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("fascicoli.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("messages.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
added_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
fascicolo: Mapped["Fascicolo"] = relationship("Fascicolo", back_populates="fascicolo_messages")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_fascicolo_messages_fascicolo", "fascicolo_id"),
|
||||
Index("idx_fascicolo_messages_message", "message_id"),
|
||||
)
|
||||
@@ -1,10 +1,17 @@
|
||||
"""
|
||||
Modelli Label e MessageLabel – tagging messaggi.
|
||||
Modelli Label e MessageLabel – tagging messaggi con supporto tassonomia gerarchica.
|
||||
|
||||
Struttura ad albero (Feature N2 – Tassonomia di Classificazione Multi-livello):
|
||||
parent_id = NULL → Livello 0: Ambito (Area Aziendale)
|
||||
parent_id = ID ambito → Livello 1: Processo
|
||||
parent_id = ID processo → Livello 2: Classificazione (foglia)
|
||||
|
||||
Le label senza parent_id sono label "piatte" classiche (comportamento pre-esistente).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import CHAR, ForeignKey, Index, String, UniqueConstraint
|
||||
from sqlalchemy import CHAR, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -20,15 +27,25 @@ class Label(Base):
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# Tassonomia: se parent_id è None è un nodo radice (Ambito) o label piatta
|
||||
parent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("labels.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
color: Mapped[str | None] = mapped_column(CHAR(7), nullable=True) # hex #RRGGBB
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Nota: i vincoli di unicità sono gestiti da indici parziali nel DB:
|
||||
# uq_label_name_root – UNIQUE (tenant_id, name) WHERE parent_id IS NULL
|
||||
# uq_label_name_parent – UNIQUE (tenant_id, name, parent_id) WHERE parent_id IS NOT NULL
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "name", name="uq_label_name_tenant"),
|
||||
Index("idx_labels_parent", "parent_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Label {self.name!r}>"
|
||||
return f"<Label {self.name!r} parent={self.parent_id}>"
|
||||
|
||||
|
||||
class MessageLabel(Base):
|
||||
|
||||
@@ -24,6 +24,12 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
RiskLevel = Enum("low", "medium", "high", "critical", name="risk_level", create_type=False)
|
||||
ConfidentialityLevel = Enum(
|
||||
"public", "internal", "confidential", "secret",
|
||||
name="confidentiality_level",
|
||||
create_type=False,
|
||||
)
|
||||
PecDirection = Enum("inbound", "outbound", name="pec_direction", create_type=False)
|
||||
PecState = Enum(
|
||||
"draft", "queued", "sent", "accepted", "delivered", "anomaly", "failed", "received",
|
||||
@@ -105,6 +111,10 @@ class Message(Base):
|
||||
is_conserved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
conserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Rischio e Riservatezza (Feature N3)
|
||||
risk_level: Mapped[str | None] = mapped_column(RiskLevel, nullable=True)
|
||||
confidentiality: Mapped[str | None] = mapped_column(ConfidentialityLevel, nullable=True)
|
||||
|
||||
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Full-text search vector (aggiornato da trigger DB + worker per allegati)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Modello PermissionPreset – template riutilizzabili di permessi per casella.
|
||||
|
||||
Permette ad admin e supervisor di creare sottoruoli nominati (es. "Operatore Archivio")
|
||||
con combinazioni predefinite di can_read/can_send/can_manage/can_conserve.
|
||||
I preset sono per-tenant e vengono applicati opzionalmente al momento
|
||||
dell'assegnazione di un operatore a una casella.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class PermissionPreset(Base):
|
||||
__tablename__ = "permission_presets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
can_manage: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
can_conserve: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
creator: Mapped["User"] = relationship( # noqa: F821
|
||||
"User", foreign_keys=[created_by]
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "name", name="uq_preset_name_tenant"),
|
||||
Index("idx_preset_tenant", "tenant_id"),
|
||||
Index("idx_preset_created_by", "created_by"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<PermissionPreset name={self.name!r} tenant={self.tenant_id} "
|
||||
f"read={self.can_read} send={self.can_send} "
|
||||
f"manage={self.can_manage} conserve={self.can_conserve}>"
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Schemi Pydantic per Fascicolo (fascicolazione pratiche).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── Schemi base ──────────────────────────────────────────────────────────────
|
||||
|
||||
class FascicoloCreate(BaseModel):
|
||||
titolo: str = Field(..., min_length=1, max_length=255)
|
||||
numero_pratica: Optional[str] = Field(None, max_length=100)
|
||||
stato: Optional[str] = Field("aperto", pattern=r"^(aperto|chiuso|archiviato)$")
|
||||
categoria: Optional[str] = Field(None, max_length=100)
|
||||
responsabile_id: Optional[uuid.UUID] = None
|
||||
scadenza: Optional[datetime] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class FascicoloUpdate(BaseModel):
|
||||
titolo: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
numero_pratica: Optional[str] = Field(None, max_length=100)
|
||||
stato: Optional[str] = Field(None, pattern=r"^(aperto|chiuso|archiviato)$")
|
||||
categoria: Optional[str] = Field(None, max_length=100)
|
||||
responsabile_id: Optional[uuid.UUID] = None
|
||||
scadenza: Optional[datetime] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class FascicoloResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
titolo: str
|
||||
numero_pratica: Optional[str] = None
|
||||
stato: str
|
||||
categoria: Optional[str] = None
|
||||
responsabile_id: Optional[uuid.UUID] = None
|
||||
scadenza: Optional[datetime] = None
|
||||
note: Optional[str] = None
|
||||
created_by: Optional[uuid.UUID] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
message_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Messaggi nel fascicolo ───────────────────────────────────────────────────
|
||||
|
||||
class FascicoloMessageItem(BaseModel):
|
||||
"""Riepilogo di un messaggio nel fascicolo."""
|
||||
id: uuid.UUID
|
||||
subject: Optional[str] = None
|
||||
from_address: Optional[str] = None
|
||||
to_addresses: Optional[list[str]] = None
|
||||
direction: str
|
||||
pec_type: str
|
||||
state: str
|
||||
mailbox_id: uuid.UUID
|
||||
received_at: Optional[datetime] = None
|
||||
sent_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
added_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Operazioni sui messaggi del fascicolo ────────────────────────────────────
|
||||
|
||||
class FascicoloAddMessagesRequest(BaseModel):
|
||||
message_ids: list[uuid.UUID] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class FascicoloRemoveMessagesRequest(BaseModel):
|
||||
message_ids: list[uuid.UUID] = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ─── Lista fascicoli di un messaggio ─────────────────────────────────────────
|
||||
|
||||
class MessageFascicoloSummary(BaseModel):
|
||||
"""Fascicolo sintetico per la vista nel dettaglio messaggio."""
|
||||
id: uuid.UUID
|
||||
titolo: str
|
||||
numero_pratica: Optional[str] = None
|
||||
stato: str
|
||||
categoria: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
Schemi Pydantic per Label (tag) e operazioni correlate.
|
||||
Esteso con supporto tassonomia gerarchica (Feature N2).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
@@ -8,14 +9,21 @@ from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── CRUD Label ───────────────────────────────────────────────────────────────
|
||||
|
||||
class LabelCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
# Tassonomia: se valorizzato, questo nodo diventa figlio del parent indicato
|
||||
parent_id: Optional[uuid.UUID] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class LabelUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
parent_id: Optional[uuid.UUID] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class LabelResponse(BaseModel):
|
||||
@@ -23,10 +31,34 @@ class LabelResponse(BaseModel):
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
parent_id: Optional[uuid.UUID] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class LabelTreeResponse(BaseModel):
|
||||
"""
|
||||
Label con figli annidati per la vista ad albero della tassonomia.
|
||||
|
||||
Struttura restituita da GET /labels/tree:
|
||||
[ Ambito1 { children: [ Processo1 { children: [ Classificazione1, ... ] } ] } ]
|
||||
"""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
parent_id: Optional[uuid.UUID] = None
|
||||
description: Optional[str] = None
|
||||
children: list["LabelTreeResponse"] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# Necessario per il tipo ricorsivo
|
||||
LabelTreeResponse.model_rebuild()
|
||||
|
||||
|
||||
# ─── Richieste per assegnazione tag a messaggi ────────────────────────────────
|
||||
|
||||
class MessageLabelSetRequest(BaseModel):
|
||||
|
||||
@@ -76,6 +76,9 @@ class MessageResponse(BaseModel):
|
||||
pending_conservation_at: Optional[datetime] = None
|
||||
is_conserved: bool = False
|
||||
conserved_at: Optional[datetime] = None
|
||||
# Rischio e Riservatezza (Feature N3)
|
||||
risk_level: Optional[str] = None
|
||||
confidentiality: Optional[str] = None
|
||||
raw_eml_path: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -116,6 +119,9 @@ class MessageUpdateRequest(BaseModel):
|
||||
is_trashed: Optional[bool] = None
|
||||
is_pending_conservation: Optional[bool] = None
|
||||
is_conserved: Optional[bool] = None
|
||||
# Rischio e Riservatezza (Feature N3) — None = non modificare; stringa vuota = reset a NULL
|
||||
risk_level: Optional[str] = None
|
||||
confidentiality: Optional[str] = None
|
||||
|
||||
|
||||
class MessageBulkUpdateRequest(BaseModel):
|
||||
@@ -126,6 +132,8 @@ class MessageBulkUpdateRequest(BaseModel):
|
||||
is_trashed: Optional[bool] = None
|
||||
is_pending_conservation: Optional[bool] = None
|
||||
is_conserved: Optional[bool] = None
|
||||
risk_level: Optional[str] = None
|
||||
confidentiality: Optional[str] = None
|
||||
|
||||
|
||||
class MessageBulkUpdateResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Schema Pydantic per i preset di permessi (sottoruoli nominati).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PermissionPresetCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Nome del preset")
|
||||
description: str | None = Field(None, description="Descrizione opzionale")
|
||||
can_read: bool = Field(True, description="Permesso lettura messaggi")
|
||||
can_send: bool = Field(False, description="Permesso invio PEC")
|
||||
can_manage: bool = Field(False, description="Permesso gestione casella")
|
||||
can_conserve: bool = Field(False, description="Permesso conservazione documenti")
|
||||
|
||||
|
||||
class PermissionPresetUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
can_read: bool | None = None
|
||||
can_send: bool | None = None
|
||||
can_manage: bool | None = None
|
||||
can_conserve: bool | None = None
|
||||
|
||||
|
||||
class PermissionPresetResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
can_read: bool
|
||||
can_send: bool
|
||||
can_manage: bool
|
||||
can_conserve: bool
|
||||
created_by: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -10,7 +10,16 @@ from pydantic import BaseModel, field_validator
|
||||
|
||||
# Valori validi per field nelle condizioni
|
||||
CONDITION_FIELDS = Literal[
|
||||
"from_address", "to_address", "subject", "mailbox_id", "pec_type"
|
||||
"from_address",
|
||||
"to_address",
|
||||
"subject",
|
||||
"mailbox_id",
|
||||
"pec_type",
|
||||
# Tassonomia (N2): verifica se il messaggio ha gia' una specifica etichetta/nodo
|
||||
"has_label",
|
||||
# Rischio e Riservatezza (N3): verifica il livello gia' impostato
|
||||
"risk_level",
|
||||
"confidentiality",
|
||||
]
|
||||
# Operatori supportati
|
||||
CONDITION_OPERATORS = Literal[
|
||||
@@ -18,7 +27,16 @@ CONDITION_OPERATORS = Literal[
|
||||
]
|
||||
# Tipi di azione
|
||||
ACTION_TYPES = Literal[
|
||||
"apply_label", "assign_vbox", "mark_read", "mark_starred", "notify_webhook"
|
||||
"apply_label",
|
||||
"assign_vbox",
|
||||
"mark_read",
|
||||
"mark_starred",
|
||||
"notify_webhook",
|
||||
# Tassonomia (N2): applica un nodo tassonomico (Ambito/Processo/Classificazione)
|
||||
"apply_taxonomy",
|
||||
# Rischio e Riservatezza (N3): imposta il livello di rischio o riservatezza
|
||||
"set_risk_level",
|
||||
"set_confidentiality",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Service per la gestione dei Fascicoli (fascicolazione pratiche).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
|
||||
from app.models.fascicolo import Fascicolo, FascicoloMessage
|
||||
from app.models.message import Message
|
||||
from app.models.user import User
|
||||
from app.schemas.fascicolo import FascicoloCreate, FascicoloUpdate
|
||||
|
||||
|
||||
class FascicoloService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ─── CRUD Fascicolo ───────────────────────────────────────────────────────
|
||||
|
||||
async def list_fascicoli(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
stato: str | None = None,
|
||||
responsabile_id: uuid.UUID | None = None,
|
||||
search: str | None = None,
|
||||
) -> list[tuple[Fascicolo, int]]:
|
||||
"""
|
||||
Restituisce lista di (Fascicolo, message_count) con filtri opzionali.
|
||||
"""
|
||||
# Subquery per il conteggio messaggi
|
||||
count_sub = (
|
||||
select(
|
||||
FascicoloMessage.fascicolo_id,
|
||||
func.count(FascicoloMessage.message_id).label("cnt"),
|
||||
)
|
||||
.group_by(FascicoloMessage.fascicolo_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(Fascicolo, func.coalesce(count_sub.c.cnt, 0).label("message_count"))
|
||||
.outerjoin(count_sub, Fascicolo.id == count_sub.c.fascicolo_id)
|
||||
.where(Fascicolo.tenant_id == tenant_id)
|
||||
.order_by(Fascicolo.updated_at.desc())
|
||||
)
|
||||
|
||||
if stato:
|
||||
stmt = stmt.where(Fascicolo.stato == stato)
|
||||
if responsabile_id:
|
||||
stmt = stmt.where(Fascicolo.responsabile_id == responsabile_id)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
stmt = stmt.where(
|
||||
Fascicolo.titolo.ilike(pattern)
|
||||
| Fascicolo.numero_pratica.ilike(pattern)
|
||||
| Fascicolo.categoria.ilike(pattern)
|
||||
)
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.all())
|
||||
|
||||
async def get_fascicolo(
|
||||
self, tenant_id: uuid.UUID, fascicolo_id: uuid.UUID
|
||||
) -> tuple[Fascicolo, int]:
|
||||
"""Restituisce (Fascicolo, message_count) o solleva NotFoundError."""
|
||||
count_sub = (
|
||||
select(
|
||||
FascicoloMessage.fascicolo_id,
|
||||
func.count(FascicoloMessage.message_id).label("cnt"),
|
||||
)
|
||||
.group_by(FascicoloMessage.fascicolo_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Fascicolo, func.coalesce(count_sub.c.cnt, 0).label("message_count"))
|
||||
.outerjoin(count_sub, Fascicolo.id == count_sub.c.fascicolo_id)
|
||||
.where(
|
||||
Fascicolo.id == fascicolo_id,
|
||||
Fascicolo.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
raise NotFoundError(f"Fascicolo {fascicolo_id} non trovato")
|
||||
return row
|
||||
|
||||
async def create_fascicolo(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
data: FascicoloCreate,
|
||||
created_by: uuid.UUID,
|
||||
) -> tuple[Fascicolo, int]:
|
||||
fascicolo = Fascicolo(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
titolo=data.titolo,
|
||||
numero_pratica=data.numero_pratica,
|
||||
stato=data.stato or "aperto",
|
||||
categoria=data.categoria,
|
||||
responsabile_id=data.responsabile_id,
|
||||
scadenza=data.scadenza,
|
||||
note=data.note,
|
||||
created_by=created_by,
|
||||
)
|
||||
self.db.add(fascicolo)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(fascicolo)
|
||||
return fascicolo, 0
|
||||
|
||||
async def update_fascicolo(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
fascicolo_id: uuid.UUID,
|
||||
data: FascicoloUpdate,
|
||||
current_user_id: uuid.UUID,
|
||||
is_admin: bool,
|
||||
) -> tuple[Fascicolo, int]:
|
||||
fascicolo, count = await self.get_fascicolo(tenant_id, fascicolo_id)
|
||||
|
||||
# Solo admin o il creatore possono modificare
|
||||
if not is_admin and fascicolo.created_by != current_user_id:
|
||||
raise ForbiddenError("Solo il creatore o un amministratore puo' modificare questo fascicolo")
|
||||
|
||||
if data.titolo is not None:
|
||||
fascicolo.titolo = data.titolo
|
||||
if data.numero_pratica is not None:
|
||||
fascicolo.numero_pratica = data.numero_pratica
|
||||
if data.stato is not None:
|
||||
fascicolo.stato = data.stato
|
||||
if data.categoria is not None:
|
||||
fascicolo.categoria = data.categoria
|
||||
if data.responsabile_id is not None:
|
||||
fascicolo.responsabile_id = data.responsabile_id
|
||||
if data.scadenza is not None:
|
||||
fascicolo.scadenza = data.scadenza
|
||||
if data.note is not None:
|
||||
fascicolo.note = data.note
|
||||
|
||||
fascicolo.updated_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(fascicolo)
|
||||
return fascicolo, count
|
||||
|
||||
async def delete_fascicolo(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
fascicolo_id: uuid.UUID,
|
||||
) -> None:
|
||||
fascicolo, _ = await self.get_fascicolo(tenant_id, fascicolo_id)
|
||||
await self.db.delete(fascicolo)
|
||||
await self.db.commit()
|
||||
|
||||
# ─── Messaggi nel fascicolo ───────────────────────────────────────────────
|
||||
|
||||
async def get_fascicolo_messages(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
fascicolo_id: uuid.UUID,
|
||||
) -> list[tuple[Message, datetime]]:
|
||||
"""
|
||||
Restituisce lista di (Message, added_at) ordinati per added_at desc.
|
||||
"""
|
||||
# Verifica fascicolo appartiene al tenant
|
||||
await self.get_fascicolo(tenant_id, fascicolo_id)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Message, FascicoloMessage.added_at)
|
||||
.join(FascicoloMessage, Message.id == FascicoloMessage.message_id)
|
||||
.where(
|
||||
FascicoloMessage.fascicolo_id == fascicolo_id,
|
||||
Message.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(FascicoloMessage.added_at.desc())
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
async def add_messages(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
fascicolo_id: uuid.UUID,
|
||||
message_ids: list[uuid.UUID],
|
||||
added_by: uuid.UUID,
|
||||
) -> int:
|
||||
"""
|
||||
Aggiunge messaggi al fascicolo. Ignora duplicati e messaggi non del tenant.
|
||||
Restituisce il numero di messaggi aggiunti.
|
||||
"""
|
||||
await self.get_fascicolo(tenant_id, fascicolo_id)
|
||||
|
||||
# Verifica che i messaggi appartengano al tenant
|
||||
msg_result = await self.db.execute(
|
||||
select(Message.id).where(
|
||||
Message.id.in_(message_ids),
|
||||
Message.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
valid_ids = list(msg_result.scalars().all())
|
||||
|
||||
if not valid_ids:
|
||||
return 0
|
||||
|
||||
# Carica associazioni esistenti per evitare duplicati
|
||||
existing_result = await self.db.execute(
|
||||
select(FascicoloMessage.message_id).where(
|
||||
FascicoloMessage.fascicolo_id == fascicolo_id,
|
||||
FascicoloMessage.message_id.in_(valid_ids),
|
||||
)
|
||||
)
|
||||
existing_ids = set(existing_result.scalars().all())
|
||||
|
||||
added = 0
|
||||
for msg_id in valid_ids:
|
||||
if msg_id not in existing_ids:
|
||||
self.db.add(
|
||||
FascicoloMessage(
|
||||
fascicolo_id=fascicolo_id,
|
||||
message_id=msg_id,
|
||||
added_by=added_by,
|
||||
)
|
||||
)
|
||||
added += 1
|
||||
|
||||
if added:
|
||||
await self.db.commit()
|
||||
|
||||
return added
|
||||
|
||||
async def remove_messages(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
fascicolo_id: uuid.UUID,
|
||||
message_ids: list[uuid.UUID],
|
||||
) -> int:
|
||||
"""Rimuove messaggi dal fascicolo. Restituisce il numero rimosso."""
|
||||
await self.get_fascicolo(tenant_id, fascicolo_id)
|
||||
|
||||
result = await self.db.execute(
|
||||
delete(FascicoloMessage).where(
|
||||
FascicoloMessage.fascicolo_id == fascicolo_id,
|
||||
FascicoloMessage.message_id.in_(message_ids),
|
||||
).returning(FascicoloMessage.message_id)
|
||||
)
|
||||
removed = len(result.fetchall())
|
||||
if removed:
|
||||
await self.db.commit()
|
||||
return removed
|
||||
|
||||
# ─── Fascicoli di un messaggio ────────────────────────────────────────────
|
||||
|
||||
async def get_message_fascicoli(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
message_id: uuid.UUID,
|
||||
) -> list[Fascicolo]:
|
||||
"""Restituisce i fascicoli a cui appartiene un messaggio."""
|
||||
result = await self.db.execute(
|
||||
select(Fascicolo)
|
||||
.join(FascicoloMessage, Fascicolo.id == FascicoloMessage.fascicolo_id)
|
||||
.where(
|
||||
FascicoloMessage.message_id == message_id,
|
||||
Fascicolo.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(Fascicolo.titolo)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -1,16 +1,21 @@
|
||||
"""
|
||||
Service per la gestione delle Label (tag) e la loro assegnazione ai messaggi.
|
||||
Esteso con supporto alla tassonomia gerarchica (Feature N2).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ConflictError, NotFoundError
|
||||
from app.core.exceptions import NotFoundError, ValidationError
|
||||
from app.models.label import Label, MessageLabel
|
||||
from app.models.message import Message
|
||||
from app.schemas.label import LabelCreate, LabelUpdate
|
||||
from app.schemas.label import LabelCreate, LabelTreeResponse, LabelUpdate
|
||||
|
||||
# Profondità massima della tassonomia (0=Ambito, 1=Processo, 2=Classificazione)
|
||||
MAX_TAXONOMY_DEPTH = 2
|
||||
|
||||
|
||||
class LabelService:
|
||||
@@ -20,6 +25,7 @@ class LabelService:
|
||||
# ─── CRUD Label ───────────────────────────────────────────────────────────
|
||||
|
||||
async def list_labels(self, tenant_id: uuid.UUID) -> list[Label]:
|
||||
"""Lista flat di tutte le label del tenant (label piatte + nodi tassonomici)."""
|
||||
result = await self.db.execute(
|
||||
select(Label)
|
||||
.where(Label.tenant_id == tenant_id)
|
||||
@@ -27,6 +33,56 @@ class LabelService:
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_root_labels(self, tenant_id: uuid.UUID) -> list[Label]:
|
||||
"""Lista solo le label radice (parent_id IS NULL)."""
|
||||
result = await self.db.execute(
|
||||
select(Label)
|
||||
.where(Label.tenant_id == tenant_id, Label.parent_id.is_(None))
|
||||
.order_by(Label.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_label_tree(self, tenant_id: uuid.UUID) -> list[LabelTreeResponse]:
|
||||
"""
|
||||
Restituisce la tassonomia come albero annidato.
|
||||
|
||||
Struttura: [ Ambito { children: [ Processo { children: [ Classificazione ] } ] } ]
|
||||
Include anche label piatte (parent_id=None) come nodi radice.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Label)
|
||||
.where(Label.tenant_id == tenant_id)
|
||||
.order_by(Label.name)
|
||||
)
|
||||
all_labels = list(result.scalars().all())
|
||||
|
||||
# Costruisce un dizionario id -> LabelTreeResponse
|
||||
nodes: dict[uuid.UUID, LabelTreeResponse] = {}
|
||||
for lbl in all_labels:
|
||||
nodes[lbl.id] = LabelTreeResponse(
|
||||
id=lbl.id,
|
||||
tenant_id=lbl.tenant_id,
|
||||
name=lbl.name,
|
||||
color=lbl.color,
|
||||
parent_id=lbl.parent_id,
|
||||
description=lbl.description,
|
||||
children=[],
|
||||
)
|
||||
|
||||
# Collega figli ai genitori e raccoglie le radici
|
||||
roots: list[LabelTreeResponse] = []
|
||||
for lbl in all_labels:
|
||||
node = nodes[lbl.id]
|
||||
if lbl.parent_id is None:
|
||||
roots.append(node)
|
||||
elif lbl.parent_id in nodes:
|
||||
nodes[lbl.parent_id].children.append(node)
|
||||
else:
|
||||
# Orfano (parent rimosso): trattato come radice
|
||||
roots.append(node)
|
||||
|
||||
return roots
|
||||
|
||||
async def get_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> Label:
|
||||
result = await self.db.execute(
|
||||
select(Label).where(
|
||||
@@ -39,21 +95,52 @@ class LabelService:
|
||||
raise NotFoundError(f"Tag {label_id} non trovato")
|
||||
return label
|
||||
|
||||
async def create_label(self, tenant_id: uuid.UUID, data: LabelCreate) -> Label:
|
||||
# Verifica unicità
|
||||
existing = await self.db.execute(
|
||||
select(Label).where(
|
||||
async def _get_depth(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> int:
|
||||
"""
|
||||
Calcola la profondità di una label nell'albero tassonomico.
|
||||
0 = radice (Ambito), 1 = Processo, 2 = Classificazione.
|
||||
"""
|
||||
depth = 0
|
||||
current_id: Optional[uuid.UUID] = label_id
|
||||
visited: set[uuid.UUID] = set()
|
||||
|
||||
while current_id and depth <= MAX_TAXONOMY_DEPTH + 1:
|
||||
if current_id in visited:
|
||||
break # Ciclo (non dovrebbe accadere ma sicurezza)
|
||||
visited.add(current_id)
|
||||
|
||||
row = await self.db.execute(
|
||||
select(Label.parent_id).where(
|
||||
Label.id == current_id,
|
||||
Label.tenant_id == tenant_id,
|
||||
Label.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Tag '{data.name}' già esistente")
|
||||
parent_id = row.scalar_one_or_none()
|
||||
if parent_id is None:
|
||||
break
|
||||
current_id = parent_id
|
||||
depth += 1
|
||||
|
||||
return depth
|
||||
|
||||
async def create_label(self, tenant_id: uuid.UUID, data: LabelCreate) -> Label:
|
||||
# Valida il parent (se specificato)
|
||||
if data.parent_id is not None:
|
||||
await self.get_label(tenant_id, data.parent_id) # 404 se non esiste
|
||||
parent_depth = await self._get_depth(tenant_id, data.parent_id)
|
||||
if parent_depth >= MAX_TAXONOMY_DEPTH:
|
||||
raise ValidationError(
|
||||
f"La tassonomia supporta al massimo {MAX_TAXONOMY_DEPTH + 1} livelli "
|
||||
"(Ambito > Processo > Classificazione). "
|
||||
"Il nodo padre ha gia' raggiunto la profondita' massima."
|
||||
)
|
||||
|
||||
label = Label(
|
||||
tenant_id=tenant_id,
|
||||
name=data.name,
|
||||
color=data.color,
|
||||
parent_id=data.parent_id,
|
||||
description=data.description,
|
||||
)
|
||||
self.db.add(label)
|
||||
await self.db.commit()
|
||||
@@ -66,21 +153,29 @@ class LabelService:
|
||||
label = await self.get_label(tenant_id, label_id)
|
||||
|
||||
if data.name is not None:
|
||||
# Verifica unicità del nuovo nome
|
||||
existing = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.tenant_id == tenant_id,
|
||||
Label.name == data.name,
|
||||
Label.id != label_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Tag '{data.name}' già esistente")
|
||||
label.name = data.name
|
||||
|
||||
if data.color is not None:
|
||||
label.color = data.color
|
||||
|
||||
if data.description is not None:
|
||||
label.description = data.description
|
||||
|
||||
if data.parent_id is not None:
|
||||
# Impedisce cicli: il nuovo parent non può essere il nodo stesso o un suo discendente
|
||||
if data.parent_id == label_id:
|
||||
raise ValidationError("Un nodo non puo' essere genitore di se stesso")
|
||||
await self.get_label(tenant_id, data.parent_id)
|
||||
parent_depth = await self._get_depth(tenant_id, data.parent_id)
|
||||
if parent_depth >= MAX_TAXONOMY_DEPTH:
|
||||
raise ValidationError(
|
||||
"Il nodo padre ha gia' raggiunto la profondita' massima consentita."
|
||||
)
|
||||
label.parent_id = data.parent_id
|
||||
elif data.parent_id is None and "parent_id" in (data.model_fields_set or set()):
|
||||
# Esplicita rimozione del parent (promozione a radice)
|
||||
label.parent_id = None
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(label)
|
||||
return label
|
||||
@@ -113,7 +208,6 @@ class LabelService:
|
||||
label_ids: list[uuid.UUID],
|
||||
) -> list[Label]:
|
||||
"""Sostituisce tutti i tag di un messaggio con quelli indicati."""
|
||||
# Verifica che i label appartengano al tenant
|
||||
valid_ids: set[uuid.UUID] = set()
|
||||
if label_ids:
|
||||
result = await self.db.execute(
|
||||
@@ -124,12 +218,9 @@ class LabelService:
|
||||
)
|
||||
valid_ids = {lbl.id for lbl in result.scalars().all()}
|
||||
|
||||
# Rimuovi tutti i tag esistenti dal messaggio
|
||||
await self.db.execute(
|
||||
delete(MessageLabel).where(MessageLabel.message_id == message_id)
|
||||
)
|
||||
|
||||
# Aggiungi i nuovi tag validi
|
||||
for lbl_id in valid_ids:
|
||||
self.db.add(MessageLabel(message_id=message_id, label_id=lbl_id))
|
||||
|
||||
@@ -146,7 +237,6 @@ class LabelService:
|
||||
if not label_ids:
|
||||
return await self.get_message_labels(message_id, tenant_id)
|
||||
|
||||
# Verifica appartenenza al tenant
|
||||
result = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.id.in_(label_ids),
|
||||
@@ -155,7 +245,6 @@ class LabelService:
|
||||
)
|
||||
valid_labels = list(result.scalars().all())
|
||||
|
||||
# Carica tag esistenti per evitare duplicati
|
||||
existing_result = await self.db.execute(
|
||||
select(MessageLabel.label_id).where(
|
||||
MessageLabel.message_id == message_id
|
||||
@@ -199,7 +288,6 @@ class LabelService:
|
||||
if not label_ids or not message_ids:
|
||||
return 0
|
||||
|
||||
# Verifica label del tenant
|
||||
lbl_result = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.id.in_(label_ids),
|
||||
@@ -208,7 +296,6 @@ class LabelService:
|
||||
)
|
||||
valid_label_ids = [lbl.id for lbl in lbl_result.scalars().all()]
|
||||
|
||||
# Verifica messaggi del tenant
|
||||
msg_result = await self.db.execute(
|
||||
select(Message.id).where(
|
||||
Message.id.in_(message_ids),
|
||||
@@ -220,7 +307,6 @@ class LabelService:
|
||||
if not valid_label_ids or not valid_message_ids:
|
||||
return 0
|
||||
|
||||
# Carica coppie esistenti per evitare duplicati
|
||||
existing_result = await self.db.execute(
|
||||
select(MessageLabel).where(
|
||||
MessageLabel.message_id.in_(valid_message_ids),
|
||||
@@ -249,7 +335,6 @@ class LabelService:
|
||||
if not label_ids or not message_ids:
|
||||
return 0
|
||||
|
||||
# Verifica messaggi del tenant
|
||||
msg_result = await self.db.execute(
|
||||
select(Message.id).where(
|
||||
Message.id.in_(message_ids),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Servizio CRUD per i preset di permessi (sottoruoli nominati).
|
||||
|
||||
Admin e supervisor possono creare, modificare ed eliminare preset per il proprio tenant.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
|
||||
from app.models.permission_preset import PermissionPreset
|
||||
from app.models.user import User
|
||||
from app.schemas.permission_preset import PermissionPresetCreate, PermissionPresetUpdate
|
||||
|
||||
|
||||
class PermissionPresetService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
def _require_supervisor_or_admin(self, user: User) -> None:
|
||||
if not user.is_supervisor_or_admin:
|
||||
raise ForbiddenError("Solo amministratori e supervisori possono gestire i preset")
|
||||
|
||||
async def list_presets(self, tenant_id: uuid.UUID) -> list[PermissionPreset]:
|
||||
"""Ritorna tutti i preset del tenant ordinati per nome."""
|
||||
result = await self.db.execute(
|
||||
select(PermissionPreset)
|
||||
.where(PermissionPreset.tenant_id == tenant_id)
|
||||
.order_by(PermissionPreset.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_preset(self, preset_id: uuid.UUID, tenant_id: uuid.UUID) -> PermissionPreset:
|
||||
"""Recupera un preset per ID verificando che appartenga al tenant."""
|
||||
preset = await self.db.get(PermissionPreset, preset_id)
|
||||
if not preset or preset.tenant_id != tenant_id:
|
||||
raise NotFoundError("preset")
|
||||
return preset
|
||||
|
||||
async def create_preset(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
data: PermissionPresetCreate,
|
||||
created_by: User,
|
||||
) -> PermissionPreset:
|
||||
"""Crea un nuovo preset. Il nome deve essere unico per tenant."""
|
||||
self._require_supervisor_or_admin(created_by)
|
||||
|
||||
# Verifica unicita' nome
|
||||
existing = await self.db.execute(
|
||||
select(PermissionPreset).where(
|
||||
PermissionPreset.tenant_id == tenant_id,
|
||||
PermissionPreset.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Esiste gia' un preset con nome '{data.name}'")
|
||||
|
||||
preset = PermissionPreset(
|
||||
tenant_id=tenant_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
can_read=data.can_read,
|
||||
can_send=data.can_send,
|
||||
can_manage=data.can_manage,
|
||||
can_conserve=data.can_conserve,
|
||||
created_by=created_by.id,
|
||||
)
|
||||
self.db.add(preset)
|
||||
await self.db.flush()
|
||||
await self.db.refresh(preset)
|
||||
return preset
|
||||
|
||||
async def update_preset(
|
||||
self,
|
||||
preset_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
data: PermissionPresetUpdate,
|
||||
updated_by: User,
|
||||
) -> PermissionPreset:
|
||||
"""Aggiorna un preset esistente."""
|
||||
self._require_supervisor_or_admin(updated_by)
|
||||
|
||||
preset = await self.get_preset(preset_id, tenant_id)
|
||||
|
||||
# Verifica unicita' nome se cambia
|
||||
if data.name is not None and data.name != preset.name:
|
||||
existing = await self.db.execute(
|
||||
select(PermissionPreset).where(
|
||||
PermissionPreset.tenant_id == tenant_id,
|
||||
PermissionPreset.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Esiste gia' un preset con nome '{data.name}'")
|
||||
preset.name = data.name
|
||||
|
||||
if data.description is not None:
|
||||
preset.description = data.description
|
||||
if data.can_read is not None:
|
||||
preset.can_read = data.can_read
|
||||
if data.can_send is not None:
|
||||
preset.can_send = data.can_send
|
||||
if data.can_manage is not None:
|
||||
preset.can_manage = data.can_manage
|
||||
if data.can_conserve is not None:
|
||||
preset.can_conserve = data.can_conserve
|
||||
|
||||
await self.db.flush()
|
||||
await self.db.refresh(preset)
|
||||
return preset
|
||||
|
||||
async def delete_preset(
|
||||
self,
|
||||
preset_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
deleted_by: User,
|
||||
) -> None:
|
||||
"""Elimina un preset."""
|
||||
self._require_supervisor_or_admin(deleted_by)
|
||||
preset = await self.get_preset(preset_id, tenant_id)
|
||||
await self.db.delete(preset)
|
||||
await self.db.flush()
|
||||
@@ -142,10 +142,10 @@ class PermissionService:
|
||||
) -> MailboxPermission:
|
||||
"""
|
||||
Crea o aggiorna un permesso utente su una casella.
|
||||
Solo admin può gestire i permessi.
|
||||
Admin e supervisor possono gestire i permessi.
|
||||
"""
|
||||
if not granted_by.is_admin:
|
||||
raise ForbiddenError("Solo gli amministratori possono gestire i permessi")
|
||||
if not granted_by.is_supervisor_or_admin:
|
||||
raise ForbiddenError("Solo amministratori e supervisori possono gestire i permessi")
|
||||
|
||||
# Verifica che casella e utente appartengano al tenant
|
||||
mailbox = await self.db.get(Mailbox, mailbox_id)
|
||||
@@ -190,8 +190,8 @@ class PermissionService:
|
||||
user_id: uuid.UUID,
|
||||
revoked_by: User,
|
||||
) -> None:
|
||||
if not revoked_by.is_admin:
|
||||
raise ForbiddenError("Solo gli amministratori possono revocare i permessi")
|
||||
if not revoked_by.is_supervisor_or_admin:
|
||||
raise ForbiddenError("Solo amministratori e supervisori possono revocare i permessi")
|
||||
|
||||
result = await self.db.execute(
|
||||
delete(MailboxPermission).where(
|
||||
|
||||
@@ -190,11 +190,44 @@ class RoutingRuleService:
|
||||
return False
|
||||
|
||||
for cond in conditions:
|
||||
# has_label è una condizione speciale: verifica presenza di MessageLabel
|
||||
if cond.field == "has_label":
|
||||
if not await self._condition_has_label(message, cond.operator, cond.value):
|
||||
return False
|
||||
else:
|
||||
field_value = self._get_field_value(message, cond.field)
|
||||
if not self._evaluate_condition(field_value, cond.operator, cond.value):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _condition_has_label(
|
||||
self, message: Message, operator: str, value: str
|
||||
) -> bool:
|
||||
"""
|
||||
Verifica se il messaggio ha (o non ha) una specifica etichetta.
|
||||
|
||||
operator 'equals' / 'contains' → True se il messaggio HA la label con UUID=value
|
||||
operator 'not_contains' → True se il messaggio NON HA la label
|
||||
"""
|
||||
try:
|
||||
label_id = uuid.UUID(value)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
existing = await self.db.execute(
|
||||
select(MessageLabel).where(
|
||||
MessageLabel.message_id == message.id,
|
||||
MessageLabel.label_id == label_id,
|
||||
)
|
||||
)
|
||||
has_it = existing.scalar_one_or_none() is not None
|
||||
|
||||
if operator in ("equals", "contains", "starts_with", "ends_with"):
|
||||
return has_it
|
||||
elif operator == "not_contains":
|
||||
return not has_it
|
||||
return has_it
|
||||
|
||||
def _get_field_value(self, message: Message, field: str) -> str:
|
||||
"""Estrae il valore del campo dal messaggio come stringa per il confronto."""
|
||||
if field == "from_address":
|
||||
@@ -207,6 +240,11 @@ class RoutingRuleService:
|
||||
return str(message.mailbox_id)
|
||||
elif field == "pec_type":
|
||||
return message.pec_type or ""
|
||||
# Rischio e Riservatezza (N3)
|
||||
elif field == "risk_level":
|
||||
return message.risk_level or ""
|
||||
elif field == "confidentiality":
|
||||
return message.confidentiality or ""
|
||||
return ""
|
||||
|
||||
def _evaluate_condition(
|
||||
@@ -241,12 +279,25 @@ class RoutingRuleService:
|
||||
try:
|
||||
if action.action_type == "apply_label" and action.action_value:
|
||||
await self._action_apply_label(message, uuid.UUID(action.action_value))
|
||||
elif action.action_type == "apply_taxonomy" and action.action_value:
|
||||
# Applica un nodo tassonomico: identico a apply_label
|
||||
# Il nodo è una Label con parent_id valorizzato (Processo o Classificazione)
|
||||
await self._action_apply_label(message, uuid.UUID(action.action_value))
|
||||
elif action.action_type == "mark_read":
|
||||
message.is_read = True
|
||||
elif action.action_type == "mark_starred":
|
||||
message.is_starred = True
|
||||
elif action.action_type == "notify_webhook" and action.action_value:
|
||||
await self._action_notify_webhook(message, action.action_value)
|
||||
# Rischio e Riservatezza (N3)
|
||||
elif action.action_type == "set_risk_level" and action.action_value:
|
||||
valid_levels = {"low", "medium", "high", "critical"}
|
||||
if action.action_value in valid_levels:
|
||||
message.risk_level = action.action_value
|
||||
elif action.action_type == "set_confidentiality" and action.action_value:
|
||||
valid_levels = {"public", "internal", "confidential", "secret"}
|
||||
if action.action_value in valid_levels:
|
||||
message.confidentiality = action.action_value
|
||||
except Exception:
|
||||
# Le azioni non devono interrompere il flusso principale
|
||||
pass
|
||||
|
||||
@@ -19,6 +19,10 @@ import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage'
|
||||
import { ContactsPage } from '@/pages/Contacts/ContactsPage'
|
||||
import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage'
|
||||
import { SignaturesPage } from '@/pages/Signatures/SignaturesPage'
|
||||
import { FascicoliPage } from '@/pages/Fascicoli/FascicoliPage'
|
||||
import { FascicoloDetailPage } from '@/pages/Fascicoli/FascicoloDetailPage'
|
||||
import { TaxonomyPage } from '@/pages/Taxonomy/TaxonomyPage'
|
||||
import { PermissionPresetsPage } from '@/pages/PermissionPresets/PermissionPresetsPage'
|
||||
|
||||
/**
|
||||
* Routing principale dell'applicazione PEChub.
|
||||
@@ -82,6 +86,7 @@ export default function App() {
|
||||
<Route path="/mailboxes" element={<MailboxesPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/permissions" element={<PermissionsPage />} />
|
||||
<Route path="/permission-presets" element={<PermissionPresetsPage />} />
|
||||
<Route path="/virtual-boxes" element={<VirtualBoxesPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
|
||||
@@ -103,6 +108,10 @@ export default function App() {
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/deadlines" element={<DeadlinesPage />} />
|
||||
<Route path="/signatures" element={<SignaturesPage />} />
|
||||
<Route path="/fascicoli" element={<FascicoliPage />} />
|
||||
<Route path="/fascicoli/:id" element={<FascicoloDetailPage />} />
|
||||
{/* Tassonomia di classificazione multi-livello (N2) */}
|
||||
<Route path="/taxonomy" element={<TaxonomyPage />} />
|
||||
|
||||
{/* Profilo utente */}
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// ─── Tipi ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FascicoloResponse {
|
||||
id: string
|
||||
tenant_id: string
|
||||
titolo: string
|
||||
numero_pratica: string | null
|
||||
stato: 'aperto' | 'chiuso' | 'archiviato'
|
||||
categoria: string | null
|
||||
responsabile_id: string | null
|
||||
scadenza: string | null
|
||||
note: string | null
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
message_count: number
|
||||
}
|
||||
|
||||
export interface FascicoloCreate {
|
||||
titolo: string
|
||||
numero_pratica?: string | null
|
||||
stato?: 'aperto' | 'chiuso' | 'archiviato'
|
||||
categoria?: string | null
|
||||
responsabile_id?: string | null
|
||||
scadenza?: string | null
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export interface FascicoloUpdate {
|
||||
titolo?: string
|
||||
numero_pratica?: string | null
|
||||
stato?: 'aperto' | 'chiuso' | 'archiviato'
|
||||
categoria?: string | null
|
||||
responsabile_id?: string | null
|
||||
scadenza?: string | null
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export interface FascicoloMessageItem {
|
||||
id: string
|
||||
subject: string | null
|
||||
from_address: string | null
|
||||
to_addresses: string[] | null
|
||||
direction: 'inbound' | 'outbound'
|
||||
pec_type: string
|
||||
state: string
|
||||
mailbox_id: string
|
||||
received_at: string | null
|
||||
sent_at: string | null
|
||||
created_at: string
|
||||
added_at: string
|
||||
}
|
||||
|
||||
export interface MessageFascicoloSummary {
|
||||
id: string
|
||||
titolo: string
|
||||
numero_pratica: string | null
|
||||
stato: 'aperto' | 'chiuso' | 'archiviato'
|
||||
categoria: string | null
|
||||
}
|
||||
|
||||
// ─── Client API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const fascicoliApi = {
|
||||
/** Lista fascicoli con filtri opzionali */
|
||||
list: (params?: {
|
||||
stato?: string
|
||||
responsabile_id?: string
|
||||
search?: string
|
||||
}) =>
|
||||
apiClient
|
||||
.get<FascicoloResponse[]>('/fascicoli', { params })
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Dettaglio fascicolo */
|
||||
get: (id: string) =>
|
||||
apiClient.get<FascicoloResponse>(`/fascicoli/${id}`).then((r) => r.data),
|
||||
|
||||
/** Crea fascicolo */
|
||||
create: (data: FascicoloCreate) =>
|
||||
apiClient.post<FascicoloResponse>('/fascicoli', data).then((r) => r.data),
|
||||
|
||||
/** Modifica fascicolo */
|
||||
update: (id: string, data: FascicoloUpdate) =>
|
||||
apiClient
|
||||
.patch<FascicoloResponse>(`/fascicoli/${id}`, data)
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Elimina fascicolo */
|
||||
delete: (id: string) =>
|
||||
apiClient.delete(`/fascicoli/${id}`).then((r) => r.data),
|
||||
|
||||
/** Messaggi del fascicolo */
|
||||
getMessages: (id: string) =>
|
||||
apiClient
|
||||
.get<FascicoloMessageItem[]>(`/fascicoli/${id}/messages`)
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Aggiungi messaggi al fascicolo */
|
||||
addMessages: (id: string, message_ids: string[]) =>
|
||||
apiClient
|
||||
.post<{ added: number }>(`/fascicoli/${id}/messages`, { message_ids })
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Rimuovi messaggi dal fascicolo */
|
||||
removeMessages: (id: string, message_ids: string[]) =>
|
||||
apiClient
|
||||
.delete<{ removed: number }>(`/fascicoli/${id}/messages`, {
|
||||
data: { message_ids },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Fascicoli a cui appartiene un messaggio */
|
||||
getMessageFascicoli: (messageId: string) =>
|
||||
apiClient
|
||||
.get<MessageFascicoloSummary[]>(`/messages/${messageId}/fascicoli`)
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import apiClient from './client'
|
||||
import type {
|
||||
LabelCreate,
|
||||
LabelResponse,
|
||||
LabelTreeResponse,
|
||||
LabelUpdate,
|
||||
MessageBulkLabelRequest,
|
||||
MessageBulkLabelResponse,
|
||||
@@ -16,6 +17,10 @@ export const labelsApi = {
|
||||
list: () =>
|
||||
apiClient.get<LabelResponse[]>('/labels').then((r) => r.data),
|
||||
|
||||
/** Restituisce la tassonomia come albero annidato (Ambito > Processo > Classificazione). */
|
||||
getTree: () =>
|
||||
apiClient.get<LabelTreeResponse[]>('/labels/tree').then((r) => r.data),
|
||||
|
||||
create: (data: LabelCreate) =>
|
||||
apiClient.post<LabelResponse>('/labels', data).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -39,6 +39,18 @@ export interface MessageBulkUpdatePayload {
|
||||
is_conserved?: boolean
|
||||
}
|
||||
|
||||
export interface MessageUpdatePayload {
|
||||
is_read?: boolean
|
||||
is_starred?: boolean
|
||||
is_archived?: boolean
|
||||
is_trashed?: boolean
|
||||
is_pending_conservation?: boolean
|
||||
is_conserved?: boolean
|
||||
/** Rischio e Riservatezza (N3) — stringa vuota per resettare a null */
|
||||
risk_level?: string
|
||||
confidentiality?: string
|
||||
}
|
||||
|
||||
export interface MessageBulkUpdateResponse {
|
||||
updated: number
|
||||
items: MessageResponse[]
|
||||
@@ -96,6 +108,12 @@ export const messagesApi = {
|
||||
.patch<MessageResponse>(`/messages/${id}`, { is_pending_conservation: false })
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Aggiorna uno o piu' campi del messaggio (PATCH generico) — include risk_level/confidentiality (N3) */
|
||||
update: (id: string, payload: MessageUpdatePayload) =>
|
||||
apiClient
|
||||
.patch<MessageResponse>(`/messages/${id}`, payload)
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Aggiorna in blocco is_starred e/o is_archived e/o is_trashed su più messaggi */
|
||||
bulkUpdate: (payload: MessageBulkUpdatePayload) =>
|
||||
apiClient
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
PermissionPresetCreate,
|
||||
PermissionPresetResponse,
|
||||
PermissionPresetUpdate,
|
||||
} from '@/types/api.types'
|
||||
|
||||
export const permissionPresetsApi = {
|
||||
/**
|
||||
* Lista tutti i preset del tenant corrente.
|
||||
*/
|
||||
list: () =>
|
||||
apiClient
|
||||
.get<PermissionPresetResponse[]>('/permission-presets')
|
||||
.then((r) => r.data),
|
||||
|
||||
/**
|
||||
* Crea un nuovo preset.
|
||||
*/
|
||||
create: (data: PermissionPresetCreate) =>
|
||||
apiClient
|
||||
.post<PermissionPresetResponse>('/permission-presets', data)
|
||||
.then((r) => r.data),
|
||||
|
||||
/**
|
||||
* Aggiorna un preset esistente.
|
||||
*/
|
||||
update: (id: string, data: PermissionPresetUpdate) =>
|
||||
apiClient
|
||||
.put<PermissionPresetResponse>(`/permission-presets/${id}`, data)
|
||||
.then((r) => r.data),
|
||||
|
||||
/**
|
||||
* Elimina un preset.
|
||||
*/
|
||||
delete: (id: string) =>
|
||||
apiClient.delete(`/permission-presets/${id}`),
|
||||
}
|
||||
@@ -1,8 +1,30 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export type ConditionField = 'from_address' | 'to_address' | 'subject' | 'mailbox_id' | 'pec_type'
|
||||
export type ConditionField =
|
||||
| 'from_address'
|
||||
| 'to_address'
|
||||
| 'subject'
|
||||
| 'mailbox_id'
|
||||
| 'pec_type'
|
||||
/** Tassonomia (N2): verifica se il messaggio ha gia' una specifica etichetta (UUID come valore) */
|
||||
| 'has_label'
|
||||
/** Rischio e Riservatezza (N3): verifica il livello gia' impostato sul messaggio */
|
||||
| 'risk_level'
|
||||
| 'confidentiality'
|
||||
|
||||
export type ConditionOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex' | 'not_contains'
|
||||
export type ActionType = 'apply_label' | 'assign_vbox' | 'mark_read' | 'mark_starred' | 'notify_webhook'
|
||||
|
||||
export type ActionType =
|
||||
| 'apply_label'
|
||||
| 'assign_vbox'
|
||||
| 'mark_read'
|
||||
| 'mark_starred'
|
||||
| 'notify_webhook'
|
||||
/** Tassonomia (N2): applica un nodo tassonomico (Ambito/Processo/Classificazione) */
|
||||
| 'apply_taxonomy'
|
||||
/** Rischio e Riservatezza (N3): imposta il livello di rischio o riservatezza */
|
||||
| 'set_risk_level'
|
||||
| 'set_confidentiality'
|
||||
|
||||
export interface RoutingRuleCondition {
|
||||
id: string
|
||||
|
||||
@@ -59,6 +59,9 @@ import {
|
||||
BookUser,
|
||||
Calendar,
|
||||
PenLine,
|
||||
FolderOpen,
|
||||
Tags,
|
||||
Sliders,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
@@ -414,6 +417,22 @@ export function Sidebar() {
|
||||
<div>
|
||||
<div className="border-t border-gray-700 mx-4 mb-3" />
|
||||
<div className="px-2 space-y-0.5">
|
||||
<NavLink
|
||||
to="/fascicoli"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
title={collapsed ? 'Fascicoli' : undefined}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && <span>Fascicoli</span>}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/deadlines"
|
||||
className={({ isActive }) =>
|
||||
@@ -515,8 +534,14 @@ export function Sidebar() {
|
||||
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
|
||||
|
||||
<div className="space-y-0.5 px-2">
|
||||
{([
|
||||
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck },
|
||||
{ to: '/permissions', label: 'Permessi', icon: Shield },
|
||||
{ to: '/permission-presets', label: 'Preset Permessi', icon: Sliders },
|
||||
] as const).map((item) => (
|
||||
<NavLink
|
||||
to="/mailboxes"
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
@@ -526,11 +551,12 @@ export function Sidebar() {
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
title={collapsed ? 'Caselle PEC' : undefined}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<MailCheck className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && <span>Caselle PEC</span>}
|
||||
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -553,8 +579,10 @@ export function Sidebar() {
|
||||
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck },
|
||||
{ to: '/users', label: 'Utenti', icon: Users },
|
||||
{ to: '/permissions', label: 'Permessi', icon: Shield },
|
||||
{ to: '/permission-presets', label: 'Preset Permessi', icon: Sliders },
|
||||
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
|
||||
{ to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 },
|
||||
{ to: '/taxonomy', label: 'Tassonomia', icon: Tags },
|
||||
{ to: '/templates', label: 'Template messaggi', icon: FileText },
|
||||
{ to: '/signatures', label: 'Firme automatiche', icon: PenLine },
|
||||
{ to: '/notifications', label: 'Notifiche', icon: Bell },
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Badge per il Livello di Rischio e la Riservatezza (Feature N3).
|
||||
*
|
||||
* RiskLevelBadge – mostra il livello di rischio operativo
|
||||
* ConfidentialityBadge – mostra il grado di riservatezza
|
||||
*/
|
||||
|
||||
import { ShieldAlert, Lock } from 'lucide-react'
|
||||
|
||||
// ─── Configurazione colori e label ───────────────────────────────────────────
|
||||
|
||||
const RISK_CONFIG: Record<string, { label: string; bgClass: string; textClass: string; borderClass: string }> = {
|
||||
low: {
|
||||
label: 'Basso',
|
||||
bgClass: 'bg-green-50',
|
||||
textClass: 'text-green-700',
|
||||
borderClass: 'border-green-300',
|
||||
},
|
||||
medium: {
|
||||
label: 'Medio',
|
||||
bgClass: 'bg-yellow-50',
|
||||
textClass: 'text-yellow-700',
|
||||
borderClass: 'border-yellow-300',
|
||||
},
|
||||
high: {
|
||||
label: 'Alto',
|
||||
bgClass: 'bg-orange-50',
|
||||
textClass: 'text-orange-700',
|
||||
borderClass: 'border-orange-300',
|
||||
},
|
||||
critical: {
|
||||
label: 'Critico',
|
||||
bgClass: 'bg-red-50',
|
||||
textClass: 'text-red-700',
|
||||
borderClass: 'border-red-300',
|
||||
},
|
||||
}
|
||||
|
||||
const CONFIDENTIALITY_CONFIG: Record<string, { label: string; bgClass: string; textClass: string; borderClass: string }> = {
|
||||
public: {
|
||||
label: 'Pubblico',
|
||||
bgClass: 'bg-gray-50',
|
||||
textClass: 'text-gray-600',
|
||||
borderClass: 'border-gray-300',
|
||||
},
|
||||
internal: {
|
||||
label: 'Interno',
|
||||
bgClass: 'bg-blue-50',
|
||||
textClass: 'text-blue-700',
|
||||
borderClass: 'border-blue-300',
|
||||
},
|
||||
confidential: {
|
||||
label: 'Riservato',
|
||||
bgClass: 'bg-purple-50',
|
||||
textClass: 'text-purple-700',
|
||||
borderClass: 'border-purple-300',
|
||||
},
|
||||
secret: {
|
||||
label: 'Segreto',
|
||||
bgClass: 'bg-red-100',
|
||||
textClass: 'text-red-800',
|
||||
borderClass: 'border-red-400',
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Componenti ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface RiskLevelBadgeProps {
|
||||
level: string
|
||||
/** Se true mostra solo il badge piccolo senza testo (per la lista messaggi) */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function RiskLevelBadge({ level, compact = false }: RiskLevelBadgeProps) {
|
||||
const config = RISK_CONFIG[level]
|
||||
if (!config) return null
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span
|
||||
title={`Rischio: ${config.label}`}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
|
||||
>
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
|
||||
>
|
||||
<ShieldAlert className="h-3.5 w-3.5" />
|
||||
Rischio: {config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConfidentialityBadgeProps {
|
||||
level: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function ConfidentialityBadge({ level, compact = false }: ConfidentialityBadgeProps) {
|
||||
const config = CONFIDENTIALITY_CONFIG[level]
|
||||
if (!config) return null
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span
|
||||
title={`Riservatezza: ${config.label}`}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Costanti esportate per i form ────────────────────────────────────────────
|
||||
|
||||
export const RISK_LEVEL_OPTIONS = [
|
||||
{ value: 'low', label: 'Basso' },
|
||||
{ value: 'medium', label: 'Medio' },
|
||||
{ value: 'high', label: 'Alto' },
|
||||
{ value: 'critical', label: 'Critico' },
|
||||
]
|
||||
|
||||
export const CONFIDENTIALITY_OPTIONS = [
|
||||
{ value: 'public', label: 'Pubblico' },
|
||||
{ value: 'internal', label: 'Interno' },
|
||||
{ value: 'confidential', label: 'Riservato' },
|
||||
{ value: 'secret', label: 'Segreto' },
|
||||
]
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
FolderOpen,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
Pencil,
|
||||
Trash2,
|
||||
FolderCheck,
|
||||
FolderArchive,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { fascicoliApi, type FascicoloResponse, type FascicoloCreate, type FascicoloUpdate } from '@/api/fascicoli.api'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
// ─── Badge stato ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatoBadge({ stato }: { stato: FascicoloResponse['stato'] }) {
|
||||
const config: Record<string, { label: string; className: string; Icon: React.ComponentType<{ className?: string }> }> = {
|
||||
aperto: {
|
||||
label: 'Aperto',
|
||||
className: 'bg-green-100 text-green-800 border border-green-200',
|
||||
Icon: FolderOpen,
|
||||
},
|
||||
chiuso: {
|
||||
label: 'Chiuso',
|
||||
className: 'bg-gray-100 text-gray-700 border border-gray-200',
|
||||
Icon: FolderCheck,
|
||||
},
|
||||
archiviato: {
|
||||
label: 'Archiviato',
|
||||
className: 'bg-amber-100 text-amber-800 border border-amber-200',
|
||||
Icon: FolderArchive,
|
||||
},
|
||||
}
|
||||
const { label, className, Icon } = config[stato] ?? config.aperto
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Dialog crea / modifica fascicolo ─────────────────────────────────────────
|
||||
|
||||
interface FascicoloDialogProps {
|
||||
fascicolo?: FascicoloResponse
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function FascicoloDialog({ fascicolo, onClose }: FascicoloDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const isEdit = !!fascicolo
|
||||
|
||||
const [form, setForm] = useState<FascicoloCreate & { stato: 'aperto' | 'chiuso' | 'archiviato' }>({
|
||||
titolo: fascicolo?.titolo ?? '',
|
||||
numero_pratica: fascicolo?.numero_pratica ?? '',
|
||||
stato: fascicolo?.stato ?? 'aperto',
|
||||
categoria: fascicolo?.categoria ?? '',
|
||||
scadenza: fascicolo?.scadenza ? fascicolo.scadenza.substring(0, 16) : '',
|
||||
note: fascicolo?.note ?? '',
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: FascicoloCreate) => fascicoliApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
|
||||
toast.success('Fascicolo creato')
|
||||
onClose()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: FascicoloUpdate) => fascicoliApi.update(fascicolo!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
|
||||
toast.success('Fascicolo aggiornato')
|
||||
onClose()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.titolo.trim()) {
|
||||
toast.error('Il titolo e obbligatorio')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
titolo: form.titolo.trim(),
|
||||
numero_pratica: form.numero_pratica?.trim() || null,
|
||||
stato: form.stato,
|
||||
categoria: form.categoria?.trim() || null,
|
||||
scadenza: form.scadenza ? new Date(form.scadenza).toISOString() : null,
|
||||
note: form.note?.trim() || null,
|
||||
}
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(payload)
|
||||
} else {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-xl shadow-2xl w-full max-w-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5 text-primary" />
|
||||
{isEdit ? 'Modifica fascicolo' : 'Nuovo fascicolo'}
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Titolo *</Label>
|
||||
<Input
|
||||
value={form.titolo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, titolo: e.target.value }))}
|
||||
placeholder="Es. Procedura espropriativa via Roma 12"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Numero pratica</Label>
|
||||
<Input
|
||||
value={form.numero_pratica ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, numero_pratica: e.target.value }))}
|
||||
placeholder="Es. 2024/0042"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Stato</Label>
|
||||
<select
|
||||
value={form.stato}
|
||||
onChange={(e) => setForm((f) => ({ ...f, stato: e.target.value as typeof form.stato }))}
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="aperto">Aperto</option>
|
||||
<option value="chiuso">Chiuso</option>
|
||||
<option value="archiviato">Archiviato</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Categoria</Label>
|
||||
<Input
|
||||
value={form.categoria ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value }))}
|
||||
placeholder="Es. Contenzioso, Contratti..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Scadenza</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={form.scadenza ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, scadenza: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Note</Label>
|
||||
<textarea
|
||||
value={form.note ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))}
|
||||
placeholder="Descrizione, riferimenti normativi..."
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 px-5 py-4 border-t">
|
||||
<Button variant="outline" onClick={onClose}>Annulla</Button>
|
||||
<Button onClick={handleSubmit} isLoading={isPending}>
|
||||
{isEdit ? 'Salva modifiche' : 'Crea fascicolo'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pagina principale ────────────────────────────────────────────────────────
|
||||
|
||||
export function FascicoliPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { isAdmin } = useAuth()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterStato, setFilterStato] = useState('')
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [editFascicolo, setEditFascicolo] = useState<FascicoloResponse | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<FascicoloResponse | null>(null)
|
||||
|
||||
const { data: fascicoli = [], isLoading } = useQuery({
|
||||
queryKey: ['fascicoli', filterStato, search],
|
||||
queryFn: () =>
|
||||
fascicoliApi.list({
|
||||
stato: filterStato || undefined,
|
||||
search: search || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => fascicoliApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
|
||||
toast.success('Fascicolo eliminato')
|
||||
setDeleteConfirm(null)
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5 text-primary" />
|
||||
Fascicoli
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fascicoli.length} {fascicoli.length === 1 ? 'fascicolo' : 'fascicoli'}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => { setEditFascicolo(null); setShowDialog(true) }}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Nuovo fascicolo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filtri */}
|
||||
<div className="border-b bg-background px-6 py-3 flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder="Cerca per titolo, numero pratica..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterStato}
|
||||
onChange={(e) => setFilterStato(e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">Tutti gli stati</option>
|
||||
<option value="aperto">Aperti</option>
|
||||
<option value="chiuso">Chiusi</option>
|
||||
<option value="archiviato">Archiviati</option>
|
||||
</select>
|
||||
{(search || filterStato) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setSearch(''); setFilterStato('') }}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Pulisci
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contenuto */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : fascicoli.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<FolderOpen className="h-14 w-14 mx-auto mb-4 opacity-20" />
|
||||
<p className="text-lg font-medium">Nessun fascicolo trovato</p>
|
||||
<p className="text-sm mt-1">
|
||||
{search || filterStato
|
||||
? 'Prova a modificare i filtri di ricerca.'
|
||||
: 'Crea il primo fascicolo per raggruppare le comunicazioni PEC.'}
|
||||
</p>
|
||||
{!search && !filterStato && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => { setEditFascicolo(null); setShowDialog(true) }}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Crea fascicolo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto space-y-3">
|
||||
{fascicoli.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer group"
|
||||
onClick={() => navigate(`/fascicoli/${f.id}`)}
|
||||
>
|
||||
{/* Icona stato */}
|
||||
<div className="flex-shrink-0">
|
||||
{f.stato === 'aperto' && <FolderOpen className="h-6 w-6 text-green-600" />}
|
||||
{f.stato === 'chiuso' && <FolderCheck className="h-6 w-6 text-gray-500" />}
|
||||
{f.stato === 'archiviato' && <FolderArchive className="h-6 w-6 text-amber-600" />}
|
||||
</div>
|
||||
|
||||
{/* Info principale */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-semibold truncate">{f.titolo}</p>
|
||||
<StatoBadge stato={f.stato} />
|
||||
{f.numero_pratica && (
|
||||
<span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
#{f.numero_pratica}
|
||||
</span>
|
||||
)}
|
||||
{f.categoria && (
|
||||
<span className="text-xs text-muted-foreground bg-blue-50 border border-blue-100 px-1.5 py-0.5 rounded">
|
||||
{f.categoria}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{f.message_count} {f.message_count === 1 ? 'messaggio' : 'messaggi'}
|
||||
</span>
|
||||
{f.scadenza && (
|
||||
<span className="flex items-center gap-1 text-amber-600">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Scade: {formatDate(f.scadenza)}
|
||||
</span>
|
||||
)}
|
||||
<span>Aggiornato: {formatDate(f.updated_at)}</span>
|
||||
</div>
|
||||
{f.note && (
|
||||
<p className="text-xs text-muted-foreground italic mt-0.5 truncate">{f.note}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Azioni */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditFascicolo(f)
|
||||
setShowDialog(true)
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
title="Modifica"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteConfirm(f)
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
title="Elimina"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialog crea / modifica */}
|
||||
{showDialog && (
|
||||
<FascicoloDialog
|
||||
fascicolo={editFascicolo ?? undefined}
|
||||
onClose={() => { setShowDialog(false); setEditFascicolo(null) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dialog conferma eliminazione */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-xl shadow-2xl p-6 w-full max-w-md space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
Elimina fascicolo
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Stai per eliminare il fascicolo{' '}
|
||||
<strong>{deleteConfirm.titolo}</strong>.
|
||||
I messaggi collegati non verranno eliminati.
|
||||
Questa operazione non puo essere annullata.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
Annulla
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(deleteConfirm.id)}
|
||||
>
|
||||
Elimina
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
FolderCheck,
|
||||
FolderArchive,
|
||||
MessageSquare,
|
||||
Calendar,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Plus,
|
||||
X,
|
||||
Search,
|
||||
Inbox,
|
||||
Send,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import {
|
||||
fascicoliApi,
|
||||
type FascicoloResponse,
|
||||
type FascicoloMessageItem,
|
||||
type FascicoloUpdate,
|
||||
} from '@/api/fascicoli.api'
|
||||
import { messagesApi } from '@/api/messages.api'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
|
||||
|
||||
// ─── Badge stato ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatoBadge({ stato }: { stato: FascicoloResponse['stato'] }) {
|
||||
const config: Record<string, { label: string; className: string }> = {
|
||||
aperto: { label: 'Aperto', className: 'bg-green-100 text-green-800 border border-green-200' },
|
||||
chiuso: { label: 'Chiuso', className: 'bg-gray-100 text-gray-700 border border-gray-200' },
|
||||
archiviato: { label: 'Archiviato', className: 'bg-amber-100 text-amber-800 border border-amber-200' },
|
||||
}
|
||||
const { label, className } = config[stato] ?? config.aperto
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Dialog modifica fascicolo ────────────────────────────────────────────────
|
||||
|
||||
interface EditDialogProps {
|
||||
fascicolo: FascicoloResponse
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function EditDialog({ fascicolo, onClose }: EditDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState<FascicoloUpdate & { stato: 'aperto' | 'chiuso' | 'archiviato' }>({
|
||||
titolo: fascicolo.titolo,
|
||||
numero_pratica: fascicolo.numero_pratica ?? '',
|
||||
stato: fascicolo.stato,
|
||||
categoria: fascicolo.categoria ?? '',
|
||||
scadenza: fascicolo.scadenza ? fascicolo.scadenza.substring(0, 16) : '',
|
||||
note: fascicolo.note ?? '',
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: FascicoloUpdate) => fascicoliApi.update(fascicolo.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicolo', fascicolo.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
|
||||
toast.success('Fascicolo aggiornato')
|
||||
onClose()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.titolo?.trim()) { toast.error('Il titolo e obbligatorio'); return }
|
||||
updateMutation.mutate({
|
||||
titolo: form.titolo?.trim(),
|
||||
numero_pratica: (form.numero_pratica as string)?.trim() || null,
|
||||
stato: form.stato,
|
||||
categoria: (form.categoria as string)?.trim() || null,
|
||||
scadenza: form.scadenza ? new Date(form.scadenza as string).toISOString() : null,
|
||||
note: (form.note as string)?.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-xl shadow-2xl w-full max-w-lg">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h3 className="text-lg font-semibold">Modifica fascicolo</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted"><X className="h-5 w-5" /></button>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Titolo *</Label>
|
||||
<Input value={form.titolo ?? ''} onChange={(e) => setForm((f) => ({ ...f, titolo: e.target.value }))} autoFocus />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Numero pratica</Label>
|
||||
<Input value={form.numero_pratica as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, numero_pratica: e.target.value }))} placeholder="Es. 2024/0042" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Stato</Label>
|
||||
<select value={form.stato} onChange={(e) => setForm((f) => ({ ...f, stato: e.target.value as typeof form.stato }))}
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<option value="aperto">Aperto</option>
|
||||
<option value="chiuso">Chiuso</option>
|
||||
<option value="archiviato">Archiviato</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Categoria</Label>
|
||||
<Input value={form.categoria as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Scadenza</Label>
|
||||
<Input type="datetime-local" value={form.scadenza as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, scadenza: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Note</Label>
|
||||
<textarea value={form.note as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))} rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 px-5 py-4 border-t">
|
||||
<Button variant="outline" onClick={onClose}>Annulla</Button>
|
||||
<Button onClick={handleSubmit} isLoading={updateMutation.isPending}>Salva modifiche</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Modal selezione messaggi da aggiungere ───────────────────────────────────
|
||||
|
||||
interface AddMessagesModalProps {
|
||||
fascicoloId: string
|
||||
existingIds: Set<string>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function AddMessagesModal({ fascicoloId, existingIds, onClose }: AddMessagesModalProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: messages = [] } = useQuery({
|
||||
queryKey: ['messages-for-fascicolo', search],
|
||||
queryFn: async () => {
|
||||
// Carica messaggi recenti (non gia' nel fascicolo)
|
||||
const params: Record<string, string | number> = { page: 1, page_size: 50, direction: 'all' }
|
||||
if (search) params.search = search
|
||||
const result = await messagesApi.list(params)
|
||||
const items = (result as any).items ?? result
|
||||
return items.filter((m: { id: string }) => !existingIds.has(m.id))
|
||||
},
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => fascicoliApi.addMessages(fascicoloId, Array.from(selected)),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicolo', fascicoloId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicolo-messages', fascicoloId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
|
||||
toast.success(`${data.added} messaggi aggiunti al fascicolo`)
|
||||
onClose()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="bg-background rounded-xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[80vh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b flex-shrink-0">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Plus className="h-5 w-5 text-primary" />
|
||||
Aggiungi messaggi al fascicolo
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted"><X className="h-5 w-5" /></button>
|
||||
</div>
|
||||
|
||||
{/* Ricerca */}
|
||||
<div className="px-5 py-3 border-b flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input className="pl-8" placeholder="Cerca messaggi..." value={search} onChange={(e) => setSearch(e.target.value)} autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista messaggi */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-1.5">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CheckCircle2 className="h-10 w-10 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">Nessun messaggio disponibile</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg: FascicoloMessageItem & { id: string; subject?: string; from_address?: string; to_addresses?: string[]; direction?: string; state?: string; received_at?: string; sent_at?: string }) => (
|
||||
<label
|
||||
key={msg.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selected.has(msg.id) ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(msg.id)}
|
||||
onChange={() => toggleSelect(msg.id)}
|
||||
className="h-4 w-4 accent-primary flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{msg.subject || '(nessun oggetto)'}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{msg.direction === 'inbound' ? `Da: ${msg.from_address}` : `A: ${(msg.to_addresses ?? []).join(', ')}`}
|
||||
{' · '}
|
||||
{formatDate(msg.received_at || msg.sent_at || msg.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{msg.direction === 'inbound'
|
||||
? <Inbox className="h-4 w-4 text-blue-500" />
|
||||
: <Send className="h-4 w-4 text-green-500" />}
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t flex-shrink-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selected.size > 0 ? `${selected.size} messaggi selezionati` : 'Seleziona i messaggi da aggiungere'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={onClose}>Annulla</Button>
|
||||
<Button
|
||||
onClick={() => addMutation.mutate()}
|
||||
disabled={selected.size === 0}
|
||||
isLoading={addMutation.isPending}
|
||||
>
|
||||
Aggiungi {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pagina dettaglio fascicolo ───────────────────────────────────────────────
|
||||
|
||||
export function FascicoloDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { isAdmin } = useAuth()
|
||||
|
||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||
const [showAddMessages, setShowAddMessages] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||
|
||||
const { data: fascicolo, isLoading: loadingFasc } = useQuery({
|
||||
queryKey: ['fascicolo', id],
|
||||
queryFn: () => fascicoliApi.get(id!),
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
const { data: messages = [], isLoading: loadingMsgs } = useQuery({
|
||||
queryKey: ['fascicolo-messages', id],
|
||||
queryFn: () => fascicoliApi.getMessages(id!),
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
const removeMsgMutation = useMutation({
|
||||
mutationFn: (msgId: string) => fascicoliApi.removeMessages(id!, [msgId]),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicolo-messages', id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicolo', id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
|
||||
toast.success('Messaggio rimosso dal fascicolo')
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const deleteFascicoloMutation = useMutation({
|
||||
mutationFn: () => fascicoliApi.delete(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
|
||||
toast.success('Fascicolo eliminato')
|
||||
navigate('/fascicoli')
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
if (loadingFasc) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!fascicolo) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<AlertTriangle className="h-10 w-10 text-muted-foreground opacity-40" />
|
||||
<p className="text-muted-foreground">Fascicolo non trovato</p>
|
||||
<Button variant="outline" onClick={() => navigate('/fascicoli')}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Torna ai fascicoli
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const existingIds = new Set(messages.map((m) => m.id))
|
||||
|
||||
const FolderIcon = fascicolo.stato === 'aperto'
|
||||
? FolderOpen
|
||||
: fascicolo.stato === 'chiuso'
|
||||
? FolderCheck
|
||||
: FolderArchive
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b bg-background px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/fascicoli')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Fascicoli</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowEditDialog(true)}>
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
Modifica
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button variant="outline" size="sm" onClick={() => setDeleteConfirm(true)}
|
||||
className="text-destructive border-destructive/30 hover:bg-destructive/10">
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Elimina
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intestazione fascicolo */}
|
||||
<div className="border-b bg-muted/30 px-6 py-5">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-start gap-4">
|
||||
<FolderIcon className={`h-10 w-10 flex-shrink-0 mt-0.5 ${
|
||||
fascicolo.stato === 'aperto' ? 'text-green-600'
|
||||
: fascicolo.stato === 'chiuso' ? 'text-gray-500'
|
||||
: 'text-amber-600'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-xl font-bold">{fascicolo.titolo}</h1>
|
||||
<StatoBadge stato={fascicolo.stato} />
|
||||
{fascicolo.numero_pratica && (
|
||||
<span className="text-sm text-muted-foreground font-mono bg-background border px-2 py-0.5 rounded">
|
||||
#{fascicolo.numero_pratica}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mt-2 text-sm text-muted-foreground flex-wrap">
|
||||
{fascicolo.categoria && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-medium">Categoria:</span> {fascicolo.categoria}
|
||||
</span>
|
||||
)}
|
||||
{fascicolo.scadenza && (
|
||||
<span className="flex items-center gap-1.5 text-amber-700">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="font-medium">Scadenza:</span> {formatDate(fascicolo.scadenza)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{fascicolo.message_count} {fascicolo.message_count === 1 ? 'messaggio' : 'messaggi'}
|
||||
</span>
|
||||
<span>Aggiornato: {formatDate(fascicolo.updated_at)}</span>
|
||||
</div>
|
||||
{fascicolo.note && (
|
||||
<p className="mt-2 text-sm text-muted-foreground italic">{fascicolo.note}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sezione messaggi */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-primary" />
|
||||
Messaggi nel fascicolo ({messages.length})
|
||||
</h2>
|
||||
<Button size="sm" onClick={() => setShowAddMessages(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Aggiungi messaggi
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadingMsgs ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center py-14 text-muted-foreground border-2 border-dashed rounded-xl">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="font-medium">Nessun messaggio nel fascicolo</p>
|
||||
<p className="text-sm mt-1">Aggiungi messaggi PEC per costruire il fascicolo.</p>
|
||||
<Button className="mt-4" size="sm" onClick={() => setShowAddMessages(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Aggiungi messaggi
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow group"
|
||||
>
|
||||
{/* Icona direzione */}
|
||||
<div className="flex-shrink-0">
|
||||
{msg.direction === 'inbound'
|
||||
? <Inbox className="h-5 w-5 text-blue-500" />
|
||||
: <Send className="h-5 w-5 text-green-500" />}
|
||||
</div>
|
||||
|
||||
{/* Info messaggio */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{msg.subject || '(nessun oggetto)'}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{msg.direction === 'inbound'
|
||||
? `Da: ${msg.from_address}`
|
||||
: `A: ${(msg.to_addresses ?? []).join(', ')}`}
|
||||
{' · '}
|
||||
{formatDate(msg.received_at || msg.sent_at || msg.created_at)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Aggiunto: {formatDate(msg.added_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badge stato */}
|
||||
<div className="flex-shrink-0">
|
||||
<PecStateBadge state={msg.state} />
|
||||
</div>
|
||||
|
||||
{/* Azioni */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/messages/${msg.id}`)}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-primary"
|
||||
title="Apri messaggio"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMsgMutation.mutate(msg.id)}
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
title="Rimuovi dal fascicolo"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal modifica */}
|
||||
{showEditDialog && (
|
||||
<EditDialog fascicolo={fascicolo} onClose={() => setShowEditDialog(false)} />
|
||||
)}
|
||||
|
||||
{/* Modal aggiungi messaggi */}
|
||||
{showAddMessages && (
|
||||
<AddMessagesModal
|
||||
fascicoloId={id!}
|
||||
existingIds={existingIds}
|
||||
onClose={() => setShowAddMessages(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Conferma eliminazione */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-xl shadow-2xl p-6 w-full max-w-md space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
Elimina fascicolo
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Stai per eliminare <strong>{fascicolo.titolo}</strong>.
|
||||
I messaggi collegati non verranno eliminati.
|
||||
Questa operazione non puo essere annullata.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(false)}>Annulla</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={deleteFascicoloMutation.isPending}
|
||||
onClick={() => deleteFascicoloMutation.mutate()}
|
||||
>
|
||||
Elimina
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import { Input } from '@/components/ui/Input'
|
||||
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
|
||||
import { TagBadgeList } from '@/components/TagManager/TagBadge'
|
||||
import { TagSelector } from '@/components/TagManager/TagSelector'
|
||||
import { RiskLevelBadge, ConfidentialityBadge } from '@/components/RiskBadge/RiskBadge'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { messagesApi } from '@/api/messages.api'
|
||||
import { labelsApi } from '@/api/labels.api'
|
||||
@@ -1093,6 +1094,14 @@ function MessageRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badge Rischio e Riservatezza (Feature N3) */}
|
||||
{(message.risk_level || message.confidentiality) && (
|
||||
<div className="flex items-center gap-1.5 mt-1" onClick={(e) => e.stopPropagation()}>
|
||||
{message.risk_level && <RiskLevelBadge level={message.risk_level} compact />}
|
||||
{message.confidentiality && <ConfidentialityBadge level={message.confidentiality} compact />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.body_text && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{truncate(message.body_text, 120)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Mail,
|
||||
Send,
|
||||
Tag,
|
||||
Tags,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
MailX,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
MessageSquare,
|
||||
X,
|
||||
ChevronDown,
|
||||
FolderOpen,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@@ -33,9 +36,12 @@ import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
|
||||
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
|
||||
import { TagBadge } from '@/components/TagManager/TagBadge'
|
||||
import { TagSelector } from '@/components/TagManager/TagSelector'
|
||||
import { RiskLevelBadge, ConfidentialityBadge, RISK_LEVEL_OPTIONS, CONFIDENTIALITY_OPTIONS } from '@/components/RiskBadge/RiskBadge'
|
||||
import { messagesApi } from '@/api/messages.api'
|
||||
import { labelsApi } from '@/api/labels.api'
|
||||
import { deadlinesApi } from '@/api/deadlines.api'
|
||||
import { fascicoliApi, type FascicoloResponse } from '@/api/fascicoli.api'
|
||||
import type { LabelResponse, MessageResponse } from '@/types/api.types'
|
||||
import { formatDate, formatBytes } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import apiClient from '@/api/client'
|
||||
@@ -158,6 +164,426 @@ function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Widget Fascicoli nel dettaglio messaggio (N1) ────────────────────────────
|
||||
|
||||
function FascicoliWidget({ messageId, navigate }: { messageId: string; navigate: (to: string) => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
|
||||
const { data: fascicoli = [] } = useQuery({
|
||||
queryKey: ['message-fascicoli', messageId],
|
||||
queryFn: () => fascicoliApi.getMessageFascicoli(messageId),
|
||||
})
|
||||
|
||||
// Lista di tutti i fascicoli del tenant per il dropdown di aggiunta
|
||||
const { data: allFascicoli = [] } = useQuery({
|
||||
queryKey: ['fascicoli'],
|
||||
queryFn: () => fascicoliApi.list(),
|
||||
enabled: showAddModal,
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (fascicoloId: string) =>
|
||||
fascicoliApi.addMessages(fascicoloId, [messageId]),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['message-fascicoli', messageId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
|
||||
toast.success('Messaggio aggiunto al fascicolo')
|
||||
setShowAddModal(false)
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const alreadyInIds = new Set(fascicoli.map((f) => f.id))
|
||||
const available = (allFascicoli as FascicoloResponse[]).filter((f) => !alreadyInIds.has(f.id) && f.stato !== 'archiviato')
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Fascicoli ({fascicoli.length})
|
||||
</h3>
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
{fascicoli.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Questo messaggio non appartiene a nessun fascicolo.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{fascicoli.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/fascicoli/${f.id}`)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{f.titolo}
|
||||
{f.numero_pratica && (
|
||||
<span className="text-xs opacity-70">#{f.numero_pratica}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-muted-foreground border border-dashed border-muted-foreground/40 hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Aggiungi a fascicolo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal selezione fascicolo */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-primary" />
|
||||
Aggiungi a fascicolo
|
||||
</h3>
|
||||
<button onClick={() => setShowAddModal(false)} className="p-1 rounded hover:bg-muted">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-3 max-h-72 overflow-y-auto space-y-1">
|
||||
{available.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<FolderOpen className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-sm">
|
||||
{allFascicoli.length === 0
|
||||
? 'Nessun fascicolo disponibile. Creane uno dalla pagina Fascicoli.'
|
||||
: 'Il messaggio e gia\' in tutti i fascicoli aperti.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
available.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => addMutation.mutate(f.id)}
|
||||
disabled={addMutation.isPending}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{f.titolo}</p>
|
||||
{f.numero_pratica && (
|
||||
<p className="text-xs text-muted-foreground">#{f.numero_pratica}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center px-5 py-3 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/fascicoli')}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Gestisci fascicoli
|
||||
</button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddModal(false)}>
|
||||
Chiudi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Widget Tassonomia nel dettaglio messaggio (N2) ───────────────────────────
|
||||
|
||||
function TaxonomyWidget({ messageId, messageLabels }: { messageId: string; messageLabels: LabelResponse[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [selectedLabelId, setSelectedLabelId] = useState('')
|
||||
|
||||
// Lista flat di tutte le label del tenant (per costruire i percorsi)
|
||||
const { data: allLabels = [] } = useQuery({
|
||||
queryKey: ['labels'],
|
||||
queryFn: () => labelsApi.list(),
|
||||
})
|
||||
|
||||
// Le label tassonomiche del messaggio sono quelle con parent_id != null
|
||||
const taxonomyLabels = messageLabels.filter((l) => l.parent_id !== null)
|
||||
|
||||
// Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione"
|
||||
function buildPath(labelId: string): string {
|
||||
const map = new Map<string, LabelResponse>(allLabels.map((l: LabelResponse) => [l.id, l]))
|
||||
const parts: string[] = []
|
||||
let current: LabelResponse | undefined = map.get(labelId)
|
||||
while (current) {
|
||||
parts.unshift(current.name)
|
||||
const parentId = current.parent_id
|
||||
if (parentId) {
|
||||
current = map.get(parentId)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return parts.join(' > ')
|
||||
}
|
||||
|
||||
// Label disponibili per l'aggiunta (non ancora assegnate)
|
||||
const alreadyAssignedIds = new Set(messageLabels.map((l) => l.id))
|
||||
const availableForAdd = allLabels.filter((l: LabelResponse) => !alreadyAssignedIds.has(l.id))
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (labelId: string) =>
|
||||
labelsApi.addMessageLabels(messageId, { label_ids: [labelId] }),
|
||||
onSuccess: (updatedLabels: LabelResponse[]) => {
|
||||
queryClient.setQueryData(['message', messageId], (old: any) => {
|
||||
if (!old) return old
|
||||
return { ...old, labels: updatedLabels }
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||
setShowAddModal(false)
|
||||
setSelectedLabelId('')
|
||||
toast.success('Classificazione aggiunta')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (labelId: string) =>
|
||||
labelsApi.removeMessageLabels(messageId, { label_ids: [labelId] }),
|
||||
onSuccess: (updatedLabels: LabelResponse[]) => {
|
||||
queryClient.setQueryData(['message', messageId], (old: any) => {
|
||||
if (!old) return old
|
||||
return { ...old, labels: updatedLabels }
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||
toast.success('Classificazione rimossa')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Tags className="h-4 w-4" />
|
||||
Classificazione tassonomica ({taxonomyLabels.length})
|
||||
</h3>
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
{taxonomyLabels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nessuna classificazione tassonomica assegnata a questo messaggio.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{taxonomyLabels.map((lbl) => {
|
||||
const path = buildPath(lbl.id)
|
||||
return (
|
||||
<span
|
||||
key={lbl.id}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm border bg-background"
|
||||
style={
|
||||
lbl.color
|
||||
? {
|
||||
backgroundColor: lbl.color + '20',
|
||||
borderColor: lbl.color + '60',
|
||||
color: lbl.color,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{lbl.color && (
|
||||
<span
|
||||
className="h-2 w-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: lbl.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium">{path || lbl.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMutation.mutate(lbl.id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="ml-1 opacity-50 hover:opacity-100 transition-opacity"
|
||||
title="Rimuovi classificazione"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedLabelId(''); setShowAddModal(true) }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-muted-foreground border border-dashed border-muted-foreground/40 hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Aggiungi classificazione
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal selezione classificazione */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<Tags className="h-4 w-4 text-primary" />
|
||||
Aggiungi classificazione tassonomica
|
||||
</h3>
|
||||
<button onClick={() => setShowAddModal(false)} className="p-1 rounded hover:bg-muted">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seleziona un nodo della tassonomia da assegnare a questo messaggio.
|
||||
Il percorso mostra: Ambito > Processo > Classificazione.
|
||||
</p>
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={selectedLabelId}
|
||||
onChange={(e) => setSelectedLabelId(e.target.value)}
|
||||
>
|
||||
<option value="">-- Seleziona classificazione --</option>
|
||||
{availableForAdd.map((l: LabelResponse) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{buildPath(l.id) || l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 px-5 py-3 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddModal(false)}>
|
||||
Annulla
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!selectedLabelId}
|
||||
isLoading={addMutation.isPending}
|
||||
onClick={() => selectedLabelId && addMutation.mutate(selectedLabelId)}
|
||||
>
|
||||
Assegna
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Widget Rischio e Riservatezza (N3) ──────────────────────────────────────
|
||||
|
||||
function RiskConfidentialityWidget({
|
||||
message,
|
||||
onUpdate,
|
||||
}: {
|
||||
message: MessageResponse
|
||||
onUpdate: (updated: MessageResponse) => void
|
||||
}) {
|
||||
const [riskLevel, setRiskLevel] = useState<string>(message.risk_level ?? '')
|
||||
const [confidentiality, setConfidentiality] = useState<string>(message.confidentiality ?? '')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Sincronizza stato locale quando il messaggio cambia
|
||||
useEffect(() => {
|
||||
setRiskLevel(message.risk_level ?? '')
|
||||
setConfidentiality(message.confidentiality ?? '')
|
||||
}, [message.risk_level, message.confidentiality])
|
||||
|
||||
const hasCurrent = message.risk_level || message.confidentiality
|
||||
const hasChanges =
|
||||
riskLevel !== (message.risk_level ?? '') ||
|
||||
confidentiality !== (message.confidentiality ?? '')
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const updated = await messagesApi.update(message.id, {
|
||||
risk_level: riskLevel || '',
|
||||
confidentiality: confidentiality || '',
|
||||
})
|
||||
onUpdate(updated)
|
||||
toast.success('Rischio e riservatezza salvati')
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<span className="inline-block h-4 w-4 text-orange-500">
|
||||
{/* icona shield */}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
</span>
|
||||
Rischio e Riservatezza
|
||||
</h3>
|
||||
<div className="rounded-lg border bg-background p-4 space-y-4">
|
||||
{/* Badge correnti */}
|
||||
{hasCurrent && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{message.risk_level && (
|
||||
<RiskLevelBadge level={message.risk_level} />
|
||||
)}
|
||||
{message.confidentiality && (
|
||||
<ConfidentialityBadge level={message.confidentiality} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form di modifica */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Livello di rischio</label>
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={riskLevel}
|
||||
onChange={(e) => setRiskLevel(e.target.value)}
|
||||
>
|
||||
<option value="">-- Nessun rischio --</option>
|
||||
{RISK_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Riservatezza</label>
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={confidentiality}
|
||||
onChange={(e) => setConfidentiality(e.target.value)}
|
||||
>
|
||||
<option value="">-- Nessuna riservatezza --</option>
|
||||
{CONFIDENTIALITY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Salva
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pagina principale ────────────────────────────────────────────────────────
|
||||
|
||||
export function MessageDetailPage() {
|
||||
@@ -924,6 +1350,18 @@ export function MessageDetailPage() {
|
||||
<ThreadSection messageId={message.id} currentId={message.id} navigate={navigate} />
|
||||
)}
|
||||
|
||||
{/* Fascicoli (N1) */}
|
||||
<FascicoliWidget messageId={message.id} navigate={navigate} />
|
||||
|
||||
{/* Classificazione Tassonomica (N2) */}
|
||||
<TaxonomyWidget messageId={message.id} messageLabels={message.labels || []} />
|
||||
|
||||
{/* Rischio e Riservatezza (N3) */}
|
||||
<RiskConfidentialityWidget message={message} onUpdate={(updated) => {
|
||||
queryClient.setQueryData(['message', id], updated)
|
||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||
}} />
|
||||
|
||||
{/* Messaggio originale per ricevute */}
|
||||
{message.direction === 'inbound' && message.pec_type !== 'posta_certificata' && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Sliders, Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { permissionPresetsApi } from '@/api/permission_presets.api'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PermissionPresetResponse } from '@/types/api.types'
|
||||
|
||||
// ─── Flag visualizzazione permesso ───────────────────────────────────────────
|
||||
|
||||
interface PermissionBadgeProps {
|
||||
active: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
function PermissionBadge({ active, label }: PermissionBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
active
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'bg-muted text-muted-foreground border border-border',
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', active ? 'bg-primary' : 'bg-muted-foreground/40')} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pagina principale ────────────────────────────────────────────────────────
|
||||
|
||||
export function PermissionPresetsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [editingPreset, setEditingPreset] = useState<PermissionPresetResponse | null>(null)
|
||||
|
||||
const { data: presets = [], isLoading } = useQuery({
|
||||
queryKey: ['permission-presets'],
|
||||
queryFn: () => permissionPresetsApi.list(),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => permissionPresetsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['permission-presets'] })
|
||||
toast.success('Preset eliminato')
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const handleEdit = (preset: PermissionPresetResponse) => {
|
||||
setEditingPreset(preset)
|
||||
setShowDialog(true)
|
||||
}
|
||||
|
||||
const handleNew = () => {
|
||||
setEditingPreset(null)
|
||||
setShowDialog(true)
|
||||
}
|
||||
|
||||
const handleDelete = (preset: PermissionPresetResponse) => {
|
||||
if (!confirm(`Eliminare il preset "${preset.name}"? L'operazione non e' reversibile.`)) return
|
||||
deleteMutation.mutate(preset.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Preset Permessi</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Definisci template di permessi riutilizzabili (sottoruoli) per gli operatori
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleNew}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuovo preset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Contenuto */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : presets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Sliders className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="font-medium mb-1">Nessun preset definito</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Crea dei preset per applicare rapidamente combinazioni di permessi agli operatori.
|
||||
</p>
|
||||
<Button onClick={handleNew}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Crea primo preset
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{presets.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
onEdit={() => handleEdit(preset)}
|
||||
onDelete={() => handleDelete(preset)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialog crea/modifica */}
|
||||
{showDialog && (
|
||||
<PresetDialog
|
||||
preset={editingPreset}
|
||||
onClose={() => setShowDialog(false)}
|
||||
onSaved={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['permission-presets'] })
|
||||
setShowDialog(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Card preset ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface PresetCardProps {
|
||||
preset: PermissionPresetResponse
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
function PresetCard({ preset, onEdit, onDelete, isDeleting }: PresetCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 flex flex-col gap-3 hover:shadow-sm transition-shadow">
|
||||
{/* Titolo e descrizione */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold truncate">{preset.name}</h3>
|
||||
{preset.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{preset.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permessi */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<PermissionBadge active={preset.can_read} label="Lettura" />
|
||||
<PermissionBadge active={preset.can_send} label="Invio" />
|
||||
<PermissionBadge active={preset.can_manage} label="Gestione" />
|
||||
<PermissionBadge active={preset.can_conserve} label="Conservazione" />
|
||||
</div>
|
||||
|
||||
{/* Azioni */}
|
||||
<div className="flex justify-end gap-2 pt-1 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
Modifica
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="h-7 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
Elimina
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Dialog crea/modifica preset ─────────────────────────────────────────────
|
||||
|
||||
interface PresetDialogProps {
|
||||
preset: PermissionPresetResponse | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
function PresetDialog({ preset, onClose, onSaved }: PresetDialogProps) {
|
||||
const isEdit = preset !== null
|
||||
|
||||
const [name, setName] = useState(preset?.name ?? '')
|
||||
const [description, setDescription] = useState(preset?.description ?? '')
|
||||
const [canRead, setCanRead] = useState(preset?.can_read ?? true)
|
||||
const [canSend, setCanSend] = useState(preset?.can_send ?? false)
|
||||
const [canManage, setCanManage] = useState(preset?.can_manage ?? false)
|
||||
const [canConserve, setCanConserve] = useState(preset?.can_conserve ?? false)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
permissionPresetsApi.create({
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
can_read: canRead,
|
||||
can_send: canSend,
|
||||
can_manage: canManage,
|
||||
can_conserve: canConserve,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Preset creato')
|
||||
onSaved()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
permissionPresetsApi.update(preset!.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
can_read: canRead,
|
||||
can_send: canSend,
|
||||
can_manage: canManage,
|
||||
can_conserve: canConserve,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Preset aggiornato')
|
||||
onSaved()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('Il nome e\' obbligatorio')
|
||||
return
|
||||
}
|
||||
if (isEdit) {
|
||||
updateMutation.mutate()
|
||||
} else {
|
||||
createMutation.mutate()
|
||||
}
|
||||
}
|
||||
|
||||
const permFlags = [
|
||||
{ key: 'can_read', label: 'Lettura messaggi', desc: 'Accesso in lettura alle PEC della casella', state: canRead, setState: setCanRead },
|
||||
{ key: 'can_send', label: 'Invio PEC', desc: 'Possibilita\' di inviare messaggi dalla casella', state: canSend, setState: setCanSend },
|
||||
{ key: 'can_manage', label: 'Gestione casella', desc: 'Modifica configurazione IMAP/SMTP della casella', state: canManage, setState: setCanManage },
|
||||
{ key: 'can_conserve', label: 'Conservazione documenti', desc: 'Spostamento messaggi nella cartella di conservazione', state: canConserve, setState: setCanConserve },
|
||||
]
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Modifica preset' : 'Nuovo preset permessi'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-2">
|
||||
{/* Nome */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="preset-name">Nome <span className="text-destructive">*</span></Label>
|
||||
<input
|
||||
id="preset-name"
|
||||
type="text"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder="Es. Operatore Archivio, Operatore Invio..."
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Descrizione */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="preset-desc">Descrizione</Label>
|
||||
<textarea
|
||||
id="preset-desc"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none"
|
||||
rows={2}
|
||||
placeholder="Descrizione opzionale del preset..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permessi */}
|
||||
<div className="space-y-2">
|
||||
<Label>Permessi inclusi nel preset</Label>
|
||||
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
|
||||
{permFlags.map(({ key, label, desc, state, setState }) => (
|
||||
<label key={key} className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state}
|
||||
onChange={(e) => setState(e.target.checked)}
|
||||
className="h-4 w-4 mt-0.5 rounded border-input flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-tight">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||
Annulla
|
||||
</Button>
|
||||
<Button onClick={handleSave} isLoading={isPending} disabled={!name.trim()}>
|
||||
{isEdit ? 'Salva modifiche' : 'Crea preset'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -12,11 +12,15 @@ import {
|
||||
} from '@/components/ui/Dialog'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { permissionsApi } from '@/api/permissions.api'
|
||||
import { permissionPresetsApi } from '@/api/permission_presets.api'
|
||||
import { mailboxesApi } from '@/api/mailboxes.api'
|
||||
import { usersApi } from '@/api/users.api'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import { cn, ROLE_LABELS } from '@/lib/utils'
|
||||
import type { MailboxUserPermissionResponse } from '@/types/api.types'
|
||||
import type {
|
||||
MailboxUserPermissionResponse,
|
||||
PermissionPresetResponse,
|
||||
} from '@/types/api.types'
|
||||
|
||||
export function PermissionsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -139,9 +143,10 @@ export function PermissionsPage() {
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Utente</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Ruolo</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Lettura</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Invio</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Gestione</th>
|
||||
<th className="text-center px-3 py-3 font-medium text-muted-foreground">Lettura</th>
|
||||
<th className="text-center px-3 py-3 font-medium text-muted-foreground">Invio</th>
|
||||
<th className="text-center px-3 py-3 font-medium text-muted-foreground">Gestione</th>
|
||||
<th className="text-center px-3 py-3 font-medium text-muted-foreground">Conserv.</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -196,17 +201,22 @@ interface PermissionRowProps {
|
||||
|
||||
function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) {
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { can_read: boolean; can_send: boolean; can_manage: boolean }) =>
|
||||
permissionsApi.grant(mailboxId, perm.user_id, data),
|
||||
mutationFn: (data: {
|
||||
can_read: boolean
|
||||
can_send: boolean
|
||||
can_manage: boolean
|
||||
can_conserve: boolean
|
||||
}) => permissionsApi.grant(mailboxId, perm.user_id, data),
|
||||
onSuccess: () => toast.success('Permesso aggiornato'),
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const toggle = (field: 'can_read' | 'can_send' | 'can_manage') => {
|
||||
const toggle = (field: 'can_read' | 'can_send' | 'can_manage' | 'can_conserve') => {
|
||||
updateMutation.mutate({
|
||||
can_read: field === 'can_read' ? !perm.can_read : perm.can_read,
|
||||
can_send: field === 'can_send' ? !perm.can_send : perm.can_send,
|
||||
can_manage: field === 'can_manage' ? !perm.can_manage : perm.can_manage,
|
||||
can_conserve: field === 'can_conserve' ? !perm.can_conserve : perm.can_conserve,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -214,19 +224,20 @@ function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) {
|
||||
<tr className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium">{perm.full_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{perm.email}</p>
|
||||
<p className="font-medium">{perm.user_full_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{perm.user_email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs bg-muted px-2 py-0.5 rounded">
|
||||
{ROLE_LABELS[perm.role] || perm.role}
|
||||
{ROLE_LABELS[perm.user_role] || perm.user_role}
|
||||
</span>
|
||||
</td>
|
||||
{(['can_read', 'can_send', 'can_manage'] as const).map((field) => (
|
||||
<td key={field} className="px-4 py-3 text-center">
|
||||
{(['can_read', 'can_send', 'can_manage', 'can_conserve'] as const).map((field) => (
|
||||
<td key={field} className="px-3 py-3 text-center">
|
||||
<button
|
||||
onClick={() => toggle(field)}
|
||||
disabled={updateMutation.isPending}
|
||||
className={cn(
|
||||
'h-5 w-5 rounded border-2 inline-flex items-center justify-center transition-colors',
|
||||
perm[field]
|
||||
@@ -234,7 +245,7 @@ function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) {
|
||||
: 'border-muted-foreground/40 bg-background hover:border-primary',
|
||||
)}
|
||||
>
|
||||
{perm[field] && <span className="text-xs"></span>}
|
||||
{perm[field] && <span className="text-xs">✓</span>}
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
@@ -263,21 +274,29 @@ interface GrantPermissionDialogProps {
|
||||
|
||||
function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionDialogProps) {
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [selectedPresetId, setSelectedPresetId] = useState('')
|
||||
const [canRead, setCanRead] = useState(true)
|
||||
const [canSend, setCanSend] = useState(false)
|
||||
const [canManage, setCanManage] = useState(false)
|
||||
const [canConserve, setCanConserve] = useState(false)
|
||||
|
||||
const { data: usersData } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => usersApi.list(1, 100),
|
||||
})
|
||||
|
||||
const { data: presets = [] } = useQuery({
|
||||
queryKey: ['permission-presets'],
|
||||
queryFn: () => permissionPresetsApi.list(),
|
||||
})
|
||||
|
||||
const grantMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
permissionsApi.grant(mailboxId, selectedUserId, {
|
||||
can_read: canRead,
|
||||
can_send: canSend,
|
||||
can_manage: canManage,
|
||||
can_conserve: canConserve,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Permesso assegnato')
|
||||
@@ -288,6 +307,23 @@ function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionD
|
||||
|
||||
const users = usersData?.items?.filter((u) => u.is_active) || []
|
||||
|
||||
const applyPreset = (preset: PermissionPresetResponse) => {
|
||||
setCanRead(preset.can_read)
|
||||
setCanSend(preset.can_send)
|
||||
setCanManage(preset.can_manage)
|
||||
setCanConserve(preset.can_conserve)
|
||||
setSelectedPresetId(preset.id)
|
||||
}
|
||||
|
||||
const handlePresetChange = (presetId: string) => {
|
||||
if (!presetId) {
|
||||
setSelectedPresetId('')
|
||||
return
|
||||
}
|
||||
const preset = presets.find((p) => p.id === presetId)
|
||||
if (preset) applyPreset(preset)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
@@ -295,6 +331,7 @@ function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionD
|
||||
<DialogTitle>Assegna permesso casella</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Utente */}
|
||||
<div className="space-y-2">
|
||||
<Label>Utente</Label>
|
||||
<select
|
||||
@@ -311,19 +348,47 @@ function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionD
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Preset (opzionale) */}
|
||||
{presets.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Applica preset (opzionale)</Label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={selectedPresetId}
|
||||
onChange={(e) => handlePresetChange(e.target.value)}
|
||||
>
|
||||
<option value="">Nessun preset – configura manualmente</option>
|
||||
{presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.description ? ` – ${p.description}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Il preset pre-compila i permessi. Puoi modificarli liberamente prima di confermare.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permessi */}
|
||||
<div className="space-y-2">
|
||||
<Label>Permessi</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 rounded-md border p-3 bg-muted/30">
|
||||
{[
|
||||
{ key: 'can_read', label: 'Lettura messaggi', state: canRead, setState: setCanRead },
|
||||
{ key: 'can_send', label: 'Invio PEC', state: canSend, setState: setCanSend },
|
||||
{ key: 'can_manage', label: 'Gestione casella', state: canManage, setState: setCanManage },
|
||||
{ key: 'can_conserve', label: 'Conservazione documenti', state: canConserve, setState: setCanConserve },
|
||||
].map(({ key, label, state, setState }) => (
|
||||
<label key={key} className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state}
|
||||
onChange={(e) => setState(e.target.checked)}
|
||||
onChange={(e) => {
|
||||
setState(e.target.checked)
|
||||
setSelectedPresetId('') // Reset preset se modifica manuale
|
||||
}}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Label } from '@/components/ui/Label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
|
||||
import { routingRulesApi, type RoutingRuleResponse, type RoutingRuleCreate, type ConditionField, type ConditionOperator, type ActionType } from '@/api/routing_rules.api'
|
||||
import { labelsApi } from '@/api/labels.api'
|
||||
import type { LabelResponse, LabelTreeResponse } from '@/types/api.types'
|
||||
import { RISK_LEVEL_OPTIONS, CONFIDENTIALITY_OPTIONS } from '@/components/RiskBadge/RiskBadge'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -18,6 +20,10 @@ const FIELD_LABELS: Record<ConditionField, string> = {
|
||||
subject: 'Oggetto',
|
||||
mailbox_id: 'ID Casella',
|
||||
pec_type: 'Tipo PEC',
|
||||
has_label: 'Ha etichetta/classificazione',
|
||||
// Rischio e Riservatezza (N3)
|
||||
risk_level: 'Livello di rischio',
|
||||
confidentiality: 'Riservatezza',
|
||||
}
|
||||
|
||||
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
|
||||
@@ -35,6 +41,26 @@ const ACTION_LABELS: Record<ActionType, string> = {
|
||||
mark_read: 'Segna come letto',
|
||||
mark_starred: 'Aggiungi ai preferiti',
|
||||
notify_webhook: 'Notifica webhook',
|
||||
apply_taxonomy: 'Applica classificazione tassonomica',
|
||||
// Rischio e Riservatezza (N3)
|
||||
set_risk_level: 'Imposta livello di rischio',
|
||||
set_confidentiality: 'Imposta riservatezza',
|
||||
}
|
||||
|
||||
/** Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione" */
|
||||
function buildLabelPath(labelId: string, allLabels: LabelResponse[]): string {
|
||||
const map = new Map(allLabels.map((l) => [l.id, l]))
|
||||
const parts: string[] = []
|
||||
let current = map.get(labelId)
|
||||
while (current) {
|
||||
parts.unshift(current.name)
|
||||
if (current.parent_id) {
|
||||
current = map.get(current.parent_id)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return parts.join(' > ')
|
||||
}
|
||||
|
||||
interface Condition {
|
||||
@@ -325,12 +351,27 @@ export function RoutingRulesPage() {
|
||||
>
|
||||
{(Object.entries(OPERATOR_LABELS) as [ConditionOperator, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
{cond.field === 'has_label' ? (
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
|
||||
value={cond.value}
|
||||
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
|
||||
>
|
||||
<option value="">-- Seleziona etichetta --</option>
|
||||
{labels.map(l => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{buildLabelPath(l.id, labels) || l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={cond.value}
|
||||
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
|
||||
placeholder="Valore..."
|
||||
/>
|
||||
)}
|
||||
<button type="button" onClick={() => setFormConditions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -362,7 +403,21 @@ export function RoutingRulesPage() {
|
||||
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
|
||||
>
|
||||
<option value="">-- Seleziona etichetta --</option>
|
||||
{labels.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
{labels.map((l: LabelResponse) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{(action.action_type === 'apply_taxonomy') && (
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
|
||||
value={action.action_value}
|
||||
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
|
||||
>
|
||||
<option value="">-- Seleziona classificazione --</option>
|
||||
{labels.map((l: LabelResponse) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{buildLabelPath(l.id, labels) || l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{(action.action_type === 'notify_webhook') && (
|
||||
@@ -373,6 +428,31 @@ export function RoutingRulesPage() {
|
||||
placeholder="https://..."
|
||||
/>
|
||||
)}
|
||||
{/* Rischio e Riservatezza (N3) */}
|
||||
{(action.action_type === 'set_risk_level') && (
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
|
||||
value={action.action_value}
|
||||
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
|
||||
>
|
||||
<option value="">-- Seleziona livello --</option>
|
||||
{RISK_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{(action.action_type === 'set_confidentiality') && (
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
|
||||
value={action.action_value}
|
||||
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
|
||||
>
|
||||
<option value="">-- Seleziona riservatezza --</option>
|
||||
{CONFIDENTIALITY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button type="button" onClick={() => setFormActions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* TaxonomyPage – Gestione della Tassonomia di Classificazione Multi-livello (Feature N2).
|
||||
*
|
||||
* Struttura gerarchica:
|
||||
* Livello 0 (Radice) = Ambito (Area Aziendale) — es. "Legale", "Commerciale"
|
||||
* Livello 1 = Processo — es. "Contratti", "Contenzioso"
|
||||
* Livello 2 (Foglia) = Classificazione — es. "NDA", "Ricorso", "Fattura"
|
||||
*
|
||||
* Le classificazioni vengono poi assegnate ai messaggi tramite:
|
||||
* - Widget tassonomia nel dettaglio messaggio (selezione manuale)
|
||||
* - Regole di smistamento con azione "apply_taxonomy" (automatica)
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Tags,
|
||||
Info,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
|
||||
import { labelsApi } from '@/api/labels.api'
|
||||
import type { LabelTreeResponse } from '@/types/api.types'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ─── Livelli della tassonomia ─────────────────────────────────────────────────
|
||||
|
||||
const LEVEL_LABELS = ['Ambito', 'Processo', 'Classificazione']
|
||||
const LEVEL_COLORS = [
|
||||
'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'bg-green-100 text-green-800 border-green-200',
|
||||
]
|
||||
const LEVEL_BG = [
|
||||
'border-l-2 border-blue-400',
|
||||
'border-l-2 border-purple-400 ml-6',
|
||||
'border-l-2 border-green-400 ml-12',
|
||||
]
|
||||
|
||||
// ─── Calcola la profondita' di un nodo nell'albero ────────────────────────────
|
||||
|
||||
function getNodeDepth(node: LabelTreeResponse): number {
|
||||
if (!node.parent_id) return 0
|
||||
return -1 // verrà calcolato nel contesto dell'albero
|
||||
}
|
||||
|
||||
// ─── Componente nodo dell'albero ──────────────────────────────────────────────
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: LabelTreeResponse
|
||||
depth: number
|
||||
onAddChild: (parent: LabelTreeResponse, depth: number) => void
|
||||
onEdit: (node: LabelTreeResponse, depth: number) => void
|
||||
onDelete: (node: LabelTreeResponse) => void
|
||||
}
|
||||
|
||||
function TreeNode({ node, depth, onAddChild, onEdit, onDelete }: TreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const hasChildren = node.children.length > 0
|
||||
const levelLabel = LEVEL_LABELS[depth] ?? `Livello ${depth}`
|
||||
const levelColor = LEVEL_COLORS[depth] ?? 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
const levelBg = LEVEL_BG[depth] ?? 'border-l-2 border-gray-400 ml-16'
|
||||
|
||||
return (
|
||||
<div className={cn(levelBg, 'pl-3 py-0.5')}>
|
||||
<div className="flex items-center gap-2 py-2 px-2 rounded-lg hover:bg-muted/40 group">
|
||||
{/* Espandi/comprimi */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5" />
|
||||
)}
|
||||
|
||||
{/* Pallino colore */}
|
||||
{node.color && (
|
||||
<span
|
||||
className="h-3.5 w-3.5 rounded-full flex-shrink-0 border"
|
||||
style={{ backgroundColor: node.color }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Nome */}
|
||||
<span className="flex-1 text-sm font-medium">{node.name}</span>
|
||||
|
||||
{/* Badge livello */}
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded-full border font-medium', levelColor)}>
|
||||
{levelLabel}
|
||||
</span>
|
||||
|
||||
{/* Descrizione (tooltip) */}
|
||||
{node.description && (
|
||||
<span title={node.description} className="text-muted-foreground cursor-help">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Azioni — visibili solo al hover */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{depth < 2 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={`Aggiungi ${LEVEL_LABELS[depth + 1] ?? 'sottonodo'}`}
|
||||
onClick={() => onAddChild(node, depth + 1)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-primary" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title="Modifica"
|
||||
onClick={() => onEdit(node, depth)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title="Elimina"
|
||||
onClick={() => onDelete(node)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Figli */}
|
||||
{expanded && hasChildren && (
|
||||
<div className="mt-0.5">
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
onAddChild={onAddChild}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pagina principale ────────────────────────────────────────────────────────
|
||||
|
||||
export function TaxonomyPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Stato form
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formColor, setFormColor] = useState('')
|
||||
const [formDescription, setFormDescription] = useState('')
|
||||
const [formParentId, setFormParentId] = useState<string | null>(null)
|
||||
const [formParentName, setFormParentName] = useState<string>('')
|
||||
const [formDepth, setFormDepth] = useState(0)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
// Carica albero tassonomico
|
||||
const { data: tree = [], isLoading } = useQuery({
|
||||
queryKey: ['labels-tree'],
|
||||
queryFn: () => labelsApi.getTree(),
|
||||
})
|
||||
|
||||
// Conta i nodi totali
|
||||
function countNodes(nodes: LabelTreeResponse[]): number {
|
||||
return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0)
|
||||
}
|
||||
const totalNodes = countNodes(tree)
|
||||
|
||||
// Crea nodo
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { name: string; color?: string; description?: string; parent_id?: string | null }) =>
|
||||
labelsApi.create({ name: data.name, color: data.color || null, description: data.description || null, parent_id: data.parent_id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['labels-tree'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['labels'] })
|
||||
toast.success('Nodo creato')
|
||||
closeForm()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// Aggiorna nodo
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: { name: string; color?: string; description?: string } }) =>
|
||||
labelsApi.update(id, { name: data.name, color: data.color || null, description: data.description || null }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['labels-tree'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['labels'] })
|
||||
toast.success('Nodo aggiornato')
|
||||
closeForm()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// Elimina nodo (elimina anche tutti i figli in cascata)
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => labelsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['labels-tree'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['labels'] })
|
||||
toast.success('Nodo eliminato')
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
function openCreate(parentNode: LabelTreeResponse | null = null, depth = 0) {
|
||||
setEditingId(null)
|
||||
setFormName('')
|
||||
setFormColor('')
|
||||
setFormDescription('')
|
||||
setFormParentId(parentNode?.id ?? null)
|
||||
setFormParentName(parentNode?.name ?? '')
|
||||
setFormDepth(depth)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function openEdit(node: LabelTreeResponse, depth: number) {
|
||||
setEditingId(node.id)
|
||||
setFormName(node.name)
|
||||
setFormColor(node.color ?? '')
|
||||
setFormDescription(node.description ?? '')
|
||||
setFormParentId(node.parent_id)
|
||||
setFormParentName('')
|
||||
setFormDepth(depth)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
setFormName('')
|
||||
setFormColor('')
|
||||
setFormDescription('')
|
||||
setFormParentId(null)
|
||||
setFormParentName('')
|
||||
}
|
||||
|
||||
function handleDelete(node: LabelTreeResponse) {
|
||||
const hasChildren = node.children.length > 0
|
||||
const msg = hasChildren
|
||||
? `Eliminare "${node.name}" e tutti i suoi ${node.children.length} sottonodi? Verranno rimossi anche dai messaggi.`
|
||||
: `Eliminare "${node.name}"? Verra' rimosso anche dai messaggi.`
|
||||
if (confirm(msg)) {
|
||||
deleteMutation.mutate(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
|
||||
if (formColor && !/^#[0-9A-Fa-f]{6}$/.test(formColor)) {
|
||||
return toast.error('Il colore deve essere in formato esadecimale #RRGGBB')
|
||||
}
|
||||
if (editingId) {
|
||||
updateMutation.mutate({
|
||||
id: editingId,
|
||||
data: { name: formName.trim(), color: formColor, description: formDescription.trim() },
|
||||
})
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
name: formName.trim(),
|
||||
color: formColor,
|
||||
description: formDescription.trim(),
|
||||
parent_id: formParentId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const levelLabel = LEVEL_LABELS[formDepth] ?? `Livello ${formDepth}`
|
||||
const levelColor = LEVEL_COLORS[formDepth] ?? 'bg-gray-100 text-gray-700'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Tags className="h-5 w-5 text-primary" />
|
||||
Tassonomia di Classificazione
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Organizza la classificazione dei messaggi in una struttura gerarchica a 3 livelli
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openCreate(null, 0)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuovo Ambito
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Legenda livelli */}
|
||||
<div className="px-6 py-3 border-b bg-muted/20 flex items-center gap-6 text-sm flex-wrap">
|
||||
{LEVEL_LABELS.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<span className={cn('px-2 py-0.5 rounded-full border text-xs font-medium', LEVEL_COLORS[i])}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{i === 0 ? 'Area aziendale principale' : i === 1 ? 'Processo / sotto-area' : 'Classificazione assegnabile ai messaggi'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-muted-foreground">{totalNodes} nodi totali</span>
|
||||
</div>
|
||||
|
||||
{/* Albero */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : tree.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Tags className="h-14 w-14 mx-auto mb-4 opacity-20" />
|
||||
<p className="text-lg font-medium">Nessuna tassonomia configurata</p>
|
||||
<p className="text-sm mt-1 max-w-md mx-auto">
|
||||
Crea un Ambito per iniziare a strutturare la classificazione dei messaggi PEC.
|
||||
Ogni Ambito puo' contenere Processi, che a loro volta contengono Classificazioni.
|
||||
</p>
|
||||
<Button className="mt-6" onClick={() => openCreate(null, 0)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Crea il primo Ambito
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-w-3xl">
|
||||
{tree.map((root) => (
|
||||
<TreeNode
|
||||
key={root.id}
|
||||
node={root}
|
||||
depth={0}
|
||||
onAddChild={(parent, depth) => openCreate(parent, depth)}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialog crea/modifica nodo */}
|
||||
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{editingId ? 'Modifica nodo' : `Nuovo ${levelLabel}`}
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded-full border font-normal', levelColor)}>
|
||||
{levelLabel}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-2">
|
||||
{/* Parent path (solo in creazione) */}
|
||||
{!editingId && formParentName && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/40 rounded-lg px-3 py-2">
|
||||
<Tags className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Sotto: <strong>{formParentName}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Nome *</Label>
|
||||
<Input
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder={`Es. ${levelLabel === 'Ambito' ? 'Legale' : levelLabel === 'Processo' ? 'Contratti' : 'NDA'}`}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Descrizione (opzionale)</Label>
|
||||
<Input
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Breve descrizione del nodo..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Colore (opzionale)</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={formColor}
|
||||
onChange={(e) => setFormColor(e.target.value)}
|
||||
placeholder="#3B82F6"
|
||||
maxLength={7}
|
||||
className="font-mono"
|
||||
/>
|
||||
{formColor && /^#[0-9A-Fa-f]{6}$/.test(formColor) && (
|
||||
<div
|
||||
className="h-8 w-8 rounded-md border flex-shrink-0"
|
||||
style={{ backgroundColor: formColor }}
|
||||
/>
|
||||
)}
|
||||
{/* Palette colori rapidi */}
|
||||
<div className="flex gap-1">
|
||||
{['#3B82F6', '#8B5CF6', '#10B981', '#F59E0B', '#EF4444', '#6B7280'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setFormColor(c)}
|
||||
className="h-6 w-6 rounded-full border-2 hover:scale-110 transition-transform"
|
||||
style={{ backgroundColor: c, borderColor: formColor === c ? '#1e40af' : 'transparent' }}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={closeForm}>Annulla</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingId ? 'Salva modifiche' : `Crea ${levelLabel}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -170,23 +170,35 @@ export interface ConnectionTestResult {
|
||||
capabilities: string[] | null
|
||||
}
|
||||
|
||||
// ─── Label (Tag) ──────────────────────────────────────────────────────────────
|
||||
// ─── Label (Tag) + Tassonomia (N2) ───────────────────────────────────────────
|
||||
|
||||
export interface LabelResponse {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
color: string | null
|
||||
// Tassonomia: se valorizzato, questo nodo fa parte di un albero gerarchico
|
||||
parent_id: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
/** Nodo tassonomico con figli annidati — restituito da GET /labels/tree */
|
||||
export interface LabelTreeResponse extends LabelResponse {
|
||||
children: LabelTreeResponse[]
|
||||
}
|
||||
|
||||
export interface LabelCreate {
|
||||
name: string
|
||||
color?: string | null
|
||||
parent_id?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface LabelUpdate {
|
||||
name?: string
|
||||
color?: string | null
|
||||
parent_id?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface MessageLabelSetRequest {
|
||||
@@ -224,6 +236,11 @@ export interface SearchMatchInfo {
|
||||
in_attachments: AttachmentMatchInfo[]
|
||||
}
|
||||
|
||||
// ─── Rischio e Riservatezza (Feature N3) ─────────────────────────────────────
|
||||
|
||||
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'
|
||||
export type ConfidentialityLevel = 'public' | 'internal' | 'confidential' | 'secret'
|
||||
|
||||
// ─── Message ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type PecDirection = 'inbound' | 'outbound'
|
||||
@@ -279,6 +296,9 @@ export interface MessageResponse {
|
||||
pending_conservation_at: string | null
|
||||
is_conserved: boolean
|
||||
conserved_at: string | null
|
||||
// Rischio e Riservatezza (Feature N3)
|
||||
risk_level: RiskLevel | null
|
||||
confidentiality: ConfidentialityLevel | null
|
||||
raw_eml_path: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -353,6 +373,7 @@ export interface PermissionResponse {
|
||||
can_read: boolean
|
||||
can_send: boolean
|
||||
can_manage: boolean
|
||||
can_conserve: boolean
|
||||
granted_by: string | null
|
||||
granted_at: string
|
||||
}
|
||||
@@ -366,9 +387,9 @@ export interface PermissionGrantRequest {
|
||||
|
||||
export interface MailboxUserPermissionResponse {
|
||||
user_id: string
|
||||
email: string
|
||||
full_name: string
|
||||
role: UserRole
|
||||
user_email: string
|
||||
user_full_name: string
|
||||
user_role: UserRole
|
||||
can_read: boolean
|
||||
can_send: boolean
|
||||
can_manage: boolean
|
||||
@@ -376,6 +397,40 @@ export interface MailboxUserPermissionResponse {
|
||||
granted_at: string
|
||||
}
|
||||
|
||||
// ─── Permission Preset (sottoruoli) ───────────────────────────────────────────
|
||||
|
||||
export interface PermissionPresetResponse {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
description: string | null
|
||||
can_read: boolean
|
||||
can_send: boolean
|
||||
can_manage: boolean
|
||||
can_conserve: boolean
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PermissionPresetCreate {
|
||||
name: string
|
||||
description?: string | null
|
||||
can_read?: boolean
|
||||
can_send?: boolean
|
||||
can_manage?: boolean
|
||||
can_conserve?: boolean
|
||||
}
|
||||
|
||||
export interface PermissionPresetUpdate {
|
||||
name?: string
|
||||
description?: string | null
|
||||
can_read?: boolean
|
||||
can_send?: boolean
|
||||
can_manage?: boolean
|
||||
can_conserve?: boolean
|
||||
}
|
||||
|
||||
export interface UserMailboxPermissionResponse {
|
||||
mailbox_id: string
|
||||
email_address: string
|
||||
|
||||
Reference in New Issue
Block a user