Sistemato attività
This commit is contained in:
@@ -1,20 +1,21 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { authGuard } from './core/auth/auth.guard';
|
||||
import { requireModeratoreGuard } from './core/auth/require-moderatore.guard';
|
||||
import { ActivityForm } from './pages/activity-form/activity-form';
|
||||
import { Detail } from './pages/detail/detail';
|
||||
import { Home } from './pages/home/home';
|
||||
import { MyActivity } from './pages/my-activity/my-activity';
|
||||
import { Profile } from './pages/profile/profile';
|
||||
import { Search } from './pages/search/search';
|
||||
import { Tassonomie } from './pages/tassonomie/tassonomie';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: Home },
|
||||
{ path: 'ricerca', component: Search },
|
||||
{ path: 'attivita/dettaglio/:idAttivita', component: Detail },
|
||||
{ path: 'mie-attivita', component: MyActivity, canActivate: [authGuard] },
|
||||
{ path: 'profilo', component: Profile, canActivate: [authGuard] },
|
||||
{ path: 'nuova-attivita', component: ActivityForm, canActivate: [authGuard] },
|
||||
{ path: 'modifica-attivita/:idAttivita', component: ActivityForm, canActivate: [authGuard] },
|
||||
{ path: 'tassonomie', component: Tassonomie, canActivate: [authGuard, requireModeratoreGuard] },
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import Keycloak from 'keycloak-js';
|
||||
|
||||
import { MODERATORE_ROLE, extractRealmRoles } from './roles';
|
||||
|
||||
// La gestione delle tassonomie (Branca/Categoria/Periodo dell'anno) è riservata al ruolo
|
||||
// realm moderatore (admin lo include come composite role). Da usare insieme a authGuard.
|
||||
export const requireModeratoreGuard: CanActivateFn = () => {
|
||||
const router = inject(Router);
|
||||
const keycloak = inject(Keycloak);
|
||||
|
||||
const roles = extractRealmRoles(keycloak.tokenParsed);
|
||||
return roles.includes(MODERATORE_ROLE) ? true : router.parseUrl('/');
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { KeycloakTokenParsed } from 'keycloak-js';
|
||||
|
||||
export const MODERATORE_ROLE = 'moderatore';
|
||||
|
||||
export function extractRealmRoles(tokenParsed: KeycloakTokenParsed | undefined): string[] {
|
||||
return tokenParsed?.realm_access?.roles ?? [];
|
||||
}
|
||||
@@ -8,6 +8,10 @@ export interface Stato extends BaseTipologica {}
|
||||
export const STATO_BOZZA: Stato = { id: 'BO', nome: 'Bozza' };
|
||||
export const STATO_PUBBLICATO: Stato = { id: 'PU', nome: 'Pubblicato' };
|
||||
export const STATO_PRIVATO: Stato = { id: 'PR', nome: 'Privato' };
|
||||
// Non selezionabile direttamente dall'autore: è lo stato in cui il backend porta
|
||||
// un'attività quando l'autore sceglie "Pubblicato", in attesa dell'approvazione di
|
||||
// admin/moderatore (vedi risolviStatoPersistito in attivita.service.ts sul backend).
|
||||
export const STATO_IN_ATTESA: Stato = { id: 'IA', nome: 'In attesa di approvazione' };
|
||||
export const STATI: Stato[] = [STATO_BOZZA, STATO_PUBBLICATO, STATO_PRIVATO];
|
||||
|
||||
export interface TipoParagrafo extends BaseTipologica {}
|
||||
@@ -17,12 +21,15 @@ export const TIPO_PARAGRAFO: TipoParagrafo = { id: 'PARAGRAFO', nome: 'Paragrafo
|
||||
|
||||
export interface TipoCategoria extends BaseTipologica {}
|
||||
|
||||
export type StatoTassonomia = 'CONFERMATA' | 'DA_APPROVARE';
|
||||
|
||||
export interface Branca {
|
||||
id: number;
|
||||
nome: string;
|
||||
inizioEta?: number;
|
||||
fineEta?: number;
|
||||
colore?: string;
|
||||
stato?: StatoTassonomia;
|
||||
}
|
||||
|
||||
export interface Categoria {
|
||||
@@ -30,6 +37,7 @@ export interface Categoria {
|
||||
nome: string;
|
||||
tipo?: TipoCategoria;
|
||||
padre?: number | null;
|
||||
stato?: StatoTassonomia;
|
||||
}
|
||||
|
||||
export interface Materiale {
|
||||
@@ -43,6 +51,7 @@ export interface PeriodoAnno {
|
||||
nome: string;
|
||||
inizioMese?: number | null;
|
||||
fineMese?: number | null;
|
||||
stato?: StatoTassonomia;
|
||||
}
|
||||
|
||||
export interface Paragrafo {
|
||||
@@ -52,6 +61,14 @@ export interface Paragrafo {
|
||||
ordine: number;
|
||||
}
|
||||
|
||||
export interface Nota {
|
||||
id: number;
|
||||
attivitaId: number;
|
||||
testo: string;
|
||||
autore: string;
|
||||
dataCreazione: string;
|
||||
}
|
||||
|
||||
export interface Attivita {
|
||||
id?: number;
|
||||
nome: string;
|
||||
@@ -63,6 +80,9 @@ export interface Attivita {
|
||||
categoriaList: Categoria[];
|
||||
materialeList: Materiale[];
|
||||
periodoAnnoList: PeriodoAnno[];
|
||||
// Popolata dal backend solo per l'autore o per admin/moderatore (vedi puoGestire su
|
||||
// attivita.service.ts); vuota per chiunque altro anche quando esistono note.
|
||||
noteList?: Nota[];
|
||||
dataCreazione?: string;
|
||||
dataModifica?: string;
|
||||
utenteModifica?: string;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type TipoNotifica =
|
||||
| 'TASSONOMIA_PROPOSTA'
|
||||
| 'ATTIVITA_IN_ATTESA'
|
||||
| 'ATTIVITA_PUBBLICATA'
|
||||
| 'ATTIVITA_BOZZA_NOTA';
|
||||
|
||||
export interface Notifica {
|
||||
id: number;
|
||||
tipo: TipoNotifica;
|
||||
messaggio: string;
|
||||
link: string | null;
|
||||
letta: boolean;
|
||||
dataCreazione: string;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export type StatoTassonomia = 'CONFERMATA' | 'DA_APPROVARE';
|
||||
|
||||
export interface Branca {
|
||||
id: number;
|
||||
nome: string;
|
||||
inizioEta: number | null;
|
||||
fineEta: number | null;
|
||||
colore: string | null;
|
||||
stato: StatoTassonomia;
|
||||
creatoDaId: string | null;
|
||||
dataCreazione: string;
|
||||
dataModifica: string;
|
||||
utenteModifica: string;
|
||||
}
|
||||
|
||||
export interface TipoCategoria {
|
||||
id: string;
|
||||
nome: string;
|
||||
}
|
||||
|
||||
export interface Categoria {
|
||||
id: number;
|
||||
nome: string;
|
||||
padreId: number | null;
|
||||
tipoId: string;
|
||||
stato: StatoTassonomia;
|
||||
creatoDaId: string | null;
|
||||
dataCreazione: string;
|
||||
dataModifica: string;
|
||||
utenteModifica: string;
|
||||
}
|
||||
|
||||
export interface PeriodoAnno {
|
||||
id: number;
|
||||
nome: string;
|
||||
inizioMese: number | null;
|
||||
fineMese: number | null;
|
||||
stato: StatoTassonomia;
|
||||
creatoDaId: string | null;
|
||||
dataCreazione: string;
|
||||
dataModifica: string;
|
||||
utenteModifica: string;
|
||||
}
|
||||
|
||||
export type BrancaInput = Omit<
|
||||
Branca,
|
||||
'id' | 'stato' | 'creatoDaId' | 'dataCreazione' | 'dataModifica' | 'utenteModifica'
|
||||
>;
|
||||
export type CategoriaInput = Omit<
|
||||
Categoria,
|
||||
'id' | 'stato' | 'creatoDaId' | 'dataCreazione' | 'dataModifica' | 'utenteModifica'
|
||||
>;
|
||||
export type PeriodoAnnoInput = Omit<
|
||||
PeriodoAnno,
|
||||
'id' | 'stato' | 'creatoDaId' | 'dataCreazione' | 'dataModifica' | 'utenteModifica'
|
||||
>;
|
||||
@@ -37,4 +37,20 @@ export class AttivitaService {
|
||||
save(attivita: Attivita): Observable<void> {
|
||||
return this.http.post<void>(`${this.privateUrl}/save`, attivita);
|
||||
}
|
||||
|
||||
getListModerazione(): Observable<Attivita[]> {
|
||||
return this.http.get<Attivita[]>(`${this.privateUrl}/get/lista/moderazione`);
|
||||
}
|
||||
|
||||
approva(idAttivita: number): Observable<void> {
|
||||
return this.http.post<void>(`${this.privateUrl}/${idAttivita}/approva`, {});
|
||||
}
|
||||
|
||||
commenta(idAttivita: number, testo: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.privateUrl}/${idAttivita}/commenta`, { testo });
|
||||
}
|
||||
|
||||
eliminaNota(idNota: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.privateUrl}/note/${idNota}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Notifica } from '../models/notifica.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class NotificheService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiUrl}/private/notifiche`;
|
||||
|
||||
getLista(): Observable<Notifica[]> {
|
||||
return this.http.get<Notifica[]>(`${this.baseUrl}/`);
|
||||
}
|
||||
|
||||
getCountNonLette(): Observable<{ count: number }> {
|
||||
return this.http.get<{ count: number }>(`${this.baseUrl}/non-lette/count`);
|
||||
}
|
||||
|
||||
segnaLetta(id: number): Observable<void> {
|
||||
return this.http.put<void>(`${this.baseUrl}/${id}/letta`, {});
|
||||
}
|
||||
|
||||
segnaTutteLette(): Observable<void> {
|
||||
return this.http.put<void>(`${this.baseUrl}/letta-tutte`, {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
Branca,
|
||||
BrancaInput,
|
||||
Categoria,
|
||||
CategoriaInput,
|
||||
PeriodoAnno,
|
||||
PeriodoAnnoInput,
|
||||
TipoCategoria,
|
||||
} from '../models/tassonomia.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TassonomieService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiUrl}/private/tassonomie`;
|
||||
|
||||
listBranca(): Observable<Branca[]> {
|
||||
return this.http.get<Branca[]>(`${this.baseUrl}/branca`);
|
||||
}
|
||||
|
||||
createBranca(input: BrancaInput): Observable<Branca> {
|
||||
return this.http.post<Branca>(`${this.baseUrl}/branca`, input);
|
||||
}
|
||||
|
||||
updateBranca(id: number, input: BrancaInput): Observable<Branca> {
|
||||
return this.http.put<Branca>(`${this.baseUrl}/branca/${id}`, input);
|
||||
}
|
||||
|
||||
deleteBranca(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/branca/${id}`);
|
||||
}
|
||||
|
||||
proponiBranca(nome: string): Observable<Branca> {
|
||||
return this.http.post<Branca>(`${this.baseUrl}/proposte/branca`, { nome });
|
||||
}
|
||||
|
||||
approvaBranca(id: number): Observable<Branca> {
|
||||
return this.http.post<Branca>(`${this.baseUrl}/branca/${id}/approva`, {});
|
||||
}
|
||||
|
||||
rifiutaBranca(id: number): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/branca/${id}/rifiuta`, {});
|
||||
}
|
||||
|
||||
listCategoria(): Observable<Categoria[]> {
|
||||
return this.http.get<Categoria[]>(`${this.baseUrl}/categoria`);
|
||||
}
|
||||
|
||||
listTipoCategoria(): Observable<TipoCategoria[]> {
|
||||
return this.http.get<TipoCategoria[]>(`${this.baseUrl}/tipo-categoria`);
|
||||
}
|
||||
|
||||
createCategoria(input: CategoriaInput): Observable<Categoria> {
|
||||
return this.http.post<Categoria>(`${this.baseUrl}/categoria`, input);
|
||||
}
|
||||
|
||||
updateCategoria(id: number, input: CategoriaInput): Observable<Categoria> {
|
||||
return this.http.put<Categoria>(`${this.baseUrl}/categoria/${id}`, input);
|
||||
}
|
||||
|
||||
deleteCategoria(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/categoria/${id}`);
|
||||
}
|
||||
|
||||
proponiCategoria(nome: string): Observable<Categoria> {
|
||||
return this.http.post<Categoria>(`${this.baseUrl}/proposte/categoria`, { nome });
|
||||
}
|
||||
|
||||
approvaCategoria(id: number): Observable<Categoria> {
|
||||
return this.http.post<Categoria>(`${this.baseUrl}/categoria/${id}/approva`, {});
|
||||
}
|
||||
|
||||
rifiutaCategoria(id: number): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/categoria/${id}/rifiuta`, {});
|
||||
}
|
||||
|
||||
listPeriodoAnno(): Observable<PeriodoAnno[]> {
|
||||
return this.http.get<PeriodoAnno[]>(`${this.baseUrl}/periodoAnno`);
|
||||
}
|
||||
|
||||
createPeriodoAnno(input: PeriodoAnnoInput): Observable<PeriodoAnno> {
|
||||
return this.http.post<PeriodoAnno>(`${this.baseUrl}/periodoAnno`, input);
|
||||
}
|
||||
|
||||
updatePeriodoAnno(id: number, input: PeriodoAnnoInput): Observable<PeriodoAnno> {
|
||||
return this.http.put<PeriodoAnno>(`${this.baseUrl}/periodoAnno/${id}`, input);
|
||||
}
|
||||
|
||||
deletePeriodoAnno(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/periodoAnno/${id}`);
|
||||
}
|
||||
|
||||
proponiPeriodoAnno(nome: string): Observable<PeriodoAnno> {
|
||||
return this.http.post<PeriodoAnno>(`${this.baseUrl}/proposte/periodoAnno`, { nome });
|
||||
}
|
||||
|
||||
approvaPeriodoAnno(id: number): Observable<PeriodoAnno> {
|
||||
return this.http.post<PeriodoAnno>(`${this.baseUrl}/periodoAnno/${id}/approva`, {});
|
||||
}
|
||||
|
||||
rifiutaPeriodoAnno(id: number): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/periodoAnno/${id}/rifiuta`, {});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ export function statoStyle(idStato: string): StatoStyle {
|
||||
if (idStato === 'PR') {
|
||||
return { bg: 'var(--color-periodo-bg)', color: 'var(--color-periodo-text)', border: 'var(--color-periodo-border)' };
|
||||
}
|
||||
if (idStato === 'IA') {
|
||||
return { bg: 'var(--color-attesa-bg)', color: 'var(--color-attesa-text)', border: 'var(--color-attesa-border)' };
|
||||
}
|
||||
return { bg: 'var(--color-neutral-bg)', color: 'var(--color-neutral-text)', border: 'var(--color-neutral-border)' };
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.errore-inline {
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -47,29 +47,38 @@
|
||||
|
||||
@if (step() === 1) {
|
||||
<div class="step-content step-content--gap">
|
||||
@if (classificazioneErrore()) {
|
||||
<div class="errore-inline">{{ classificazioneErrore() }}</div>
|
||||
}
|
||||
<app-chip-field
|
||||
label="Branca"
|
||||
[chips]="brancaList()"
|
||||
[(query)]="brancaQuery"
|
||||
[suggestions]="brancaSuggestions()"
|
||||
[allowCreate]="true"
|
||||
(select)="addBranca($event)"
|
||||
(remove)="removeBranca($event)"
|
||||
(create)="onCreateBranca($event)"
|
||||
/>
|
||||
<app-chip-field
|
||||
label="Categoria"
|
||||
[chips]="categoriaList()"
|
||||
[(query)]="categoriaQuery"
|
||||
[suggestions]="categoriaSuggestions()"
|
||||
[allowCreate]="true"
|
||||
(select)="addCategoria($event)"
|
||||
(remove)="removeCategoria($event)"
|
||||
(create)="onCreateCategoria($event)"
|
||||
/>
|
||||
<app-chip-field
|
||||
label="Periodo dell'anno"
|
||||
[chips]="periodoAnnoList()"
|
||||
[(query)]="periodoQuery"
|
||||
[suggestions]="periodoSuggestions()"
|
||||
[allowCreate]="true"
|
||||
(select)="addPeriodo($event)"
|
||||
(remove)="removePeriodo($event)"
|
||||
(create)="onCreatePeriodo($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Component, Signal, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import Keycloak from 'keycloak-js';
|
||||
import { Observable, catchError, debounceTime, distinctUntilChanged, of, switchMap } from 'rxjs';
|
||||
|
||||
import {
|
||||
Attivita,
|
||||
@@ -17,10 +19,12 @@ import {
|
||||
} from '../../core/models/attivita.model';
|
||||
import { AttivitaService } from '../../core/services/attivita';
|
||||
import { AutocompleteService } from '../../core/services/autocomplete';
|
||||
import { TassonomieService } from '../../core/services/tassonomie';
|
||||
import { statoStyle } from '../../core/utils/stato-style';
|
||||
import { ChipField, ChipOption } from '../../shared/chip-field/chip-field';
|
||||
|
||||
const STEP_LABELS = ['Info base', 'Classificazione', 'Materiale', 'Paragrafi', 'Riepilogo'];
|
||||
const AUTOCOMPLETE_DEBOUNCE_MS = 300;
|
||||
|
||||
@Component({
|
||||
selector: 'app-activity-form',
|
||||
@@ -33,6 +37,7 @@ export class ActivityForm {
|
||||
private readonly router = inject(Router);
|
||||
private readonly attivitaService = inject(AttivitaService);
|
||||
private readonly autocompleteService = inject(AutocompleteService);
|
||||
private readonly tassonomieService = inject(TassonomieService);
|
||||
private readonly keycloak = inject(Keycloak);
|
||||
|
||||
readonly editingId = signal<number | null>(null);
|
||||
@@ -59,6 +64,8 @@ export class ActivityForm {
|
||||
readonly periodoSuggestions = signal<ChipOption[]>([]);
|
||||
readonly materialeSuggestions = signal<ChipOption[]>([]);
|
||||
|
||||
readonly classificazioneErrore = signal<string | null>(null);
|
||||
|
||||
readonly wizardTitle = computed(() => (this.editingId() ? 'Modifica attività' : 'Nuova attività'));
|
||||
readonly isLastStep = computed(() => this.step() === 4);
|
||||
readonly nonTitoloCount = computed(() => this.paragrafoList().filter((p) => p.tipo.id !== 'TITOLO').length);
|
||||
@@ -75,56 +82,55 @@ export class ActivityForm {
|
||||
this.attivitaService.getOne(id).subscribe((attivita) => this.popolaForm(attivita));
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const query = this.brancaQuery();
|
||||
this.autocompleteQuery(
|
||||
this.brancaQuery,
|
||||
(query) => this.autocompleteService.branca(query),
|
||||
).subscribe((r) => {
|
||||
const selezionati = new Set(this.brancaList().map((b) => b.id));
|
||||
if (!query.trim()) {
|
||||
this.brancaSuggestions.set([]);
|
||||
return;
|
||||
}
|
||||
this.autocompleteService.branca(query).subscribe({
|
||||
next: (r) => this.brancaSuggestions.set(this.toChipOptions(r, selezionati)),
|
||||
error: () => this.brancaSuggestions.set([]),
|
||||
});
|
||||
this.brancaSuggestions.set(this.toChipOptions(r, selezionati));
|
||||
});
|
||||
effect(() => {
|
||||
const query = this.categoriaQuery();
|
||||
this.autocompleteQuery(
|
||||
this.categoriaQuery,
|
||||
(query) => this.autocompleteService.categoria(query),
|
||||
).subscribe((r) => {
|
||||
const selezionati = new Set(this.categoriaList().map((c) => c.id));
|
||||
if (!query.trim()) {
|
||||
this.categoriaSuggestions.set([]);
|
||||
return;
|
||||
}
|
||||
this.autocompleteService.categoria(query).subscribe({
|
||||
next: (r) => this.categoriaSuggestions.set(this.toChipOptions(r, selezionati)),
|
||||
error: () => this.categoriaSuggestions.set([]),
|
||||
});
|
||||
this.categoriaSuggestions.set(this.toChipOptions(r, selezionati));
|
||||
});
|
||||
effect(() => {
|
||||
const query = this.periodoQuery();
|
||||
this.autocompleteQuery(
|
||||
this.periodoQuery,
|
||||
(query) => this.autocompleteService.periodoAnno(query),
|
||||
).subscribe((r) => {
|
||||
const selezionati = new Set(this.periodoAnnoList().map((p) => p.id));
|
||||
if (!query.trim()) {
|
||||
this.periodoSuggestions.set([]);
|
||||
return;
|
||||
}
|
||||
this.autocompleteService.periodoAnno(query).subscribe({
|
||||
next: (r) => this.periodoSuggestions.set(this.toChipOptions(r, selezionati)),
|
||||
error: () => this.periodoSuggestions.set([]),
|
||||
});
|
||||
this.periodoSuggestions.set(this.toChipOptions(r, selezionati));
|
||||
});
|
||||
effect(() => {
|
||||
const query = this.materialeQuery();
|
||||
this.autocompleteQuery(
|
||||
this.materialeQuery,
|
||||
(query) => this.autocompleteService.materiale(query),
|
||||
).subscribe((r) => {
|
||||
const selezionati = new Set(this.materialeList().map((m) => m.id));
|
||||
if (!query.trim()) {
|
||||
this.materialeSuggestions.set([]);
|
||||
return;
|
||||
}
|
||||
this.autocompleteService.materiale(query).subscribe({
|
||||
next: (r) => this.materialeSuggestions.set(this.toChipOptions(r, selezionati)),
|
||||
error: () => this.materialeSuggestions.set([]),
|
||||
});
|
||||
this.materialeSuggestions.set(this.toChipOptions(r, selezionati));
|
||||
});
|
||||
}
|
||||
|
||||
// Aspetta che l'utente smetta di scrivere per AUTOCOMPLETE_DEBOUNCE_MS prima di
|
||||
// interrogare il backend, invece di chiamarlo ad ogni singola lettera digitata.
|
||||
private autocompleteQuery(
|
||||
querySignal: Signal<string>,
|
||||
richiedi: (query: string) => Observable<{ id: number | null; nome: string | null }[]>,
|
||||
) {
|
||||
return toObservable(querySignal).pipe(
|
||||
debounceTime(AUTOCOMPLETE_DEBOUNCE_MS),
|
||||
distinctUntilChanged(),
|
||||
switchMap((query) => {
|
||||
if (!query.trim()) {
|
||||
return of([]);
|
||||
}
|
||||
return richiedi(query).pipe(catchError(() => of([])));
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
);
|
||||
}
|
||||
|
||||
private toChipOptions(
|
||||
risultati: { id: number | null; nome: string | null }[],
|
||||
esclusi: Set<number>
|
||||
@@ -165,6 +171,26 @@ export class ActivityForm {
|
||||
removeBranca(chip: ChipOption): void {
|
||||
this.brancaList.update((lista) => lista.filter((b) => b.id !== chip.id));
|
||||
}
|
||||
onCreateBranca(nome: string): void {
|
||||
this.classificazioneErrore.set(null);
|
||||
this.tassonomieService.proponiBranca(nome).subscribe({
|
||||
next: (branca) => {
|
||||
this.brancaList.update((lista) => [
|
||||
...lista,
|
||||
{
|
||||
id: branca.id,
|
||||
nome: branca.nome,
|
||||
inizioEta: branca.inizioEta ?? undefined,
|
||||
fineEta: branca.fineEta ?? undefined,
|
||||
colore: branca.colore ?? undefined,
|
||||
stato: branca.stato,
|
||||
},
|
||||
]);
|
||||
this.brancaQuery.set('');
|
||||
},
|
||||
error: () => this.classificazioneErrore.set('Impossibile proporre la branca.'),
|
||||
});
|
||||
}
|
||||
|
||||
addCategoria(chip: ChipOption): void {
|
||||
this.categoriaList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
|
||||
@@ -173,6 +199,19 @@ export class ActivityForm {
|
||||
removeCategoria(chip: ChipOption): void {
|
||||
this.categoriaList.update((lista) => lista.filter((c) => c.id !== chip.id));
|
||||
}
|
||||
onCreateCategoria(nome: string): void {
|
||||
this.classificazioneErrore.set(null);
|
||||
this.tassonomieService.proponiCategoria(nome).subscribe({
|
||||
next: (categoria) => {
|
||||
this.categoriaList.update((lista) => [
|
||||
...lista,
|
||||
{ id: categoria.id, nome: categoria.nome, stato: categoria.stato },
|
||||
]);
|
||||
this.categoriaQuery.set('');
|
||||
},
|
||||
error: () => this.classificazioneErrore.set('Impossibile proporre la categoria.'),
|
||||
});
|
||||
}
|
||||
|
||||
addPeriodo(chip: ChipOption): void {
|
||||
this.periodoAnnoList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
|
||||
@@ -181,6 +220,25 @@ export class ActivityForm {
|
||||
removePeriodo(chip: ChipOption): void {
|
||||
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== chip.id));
|
||||
}
|
||||
onCreatePeriodo(nome: string): void {
|
||||
this.classificazioneErrore.set(null);
|
||||
this.tassonomieService.proponiPeriodoAnno(nome).subscribe({
|
||||
next: (periodo) => {
|
||||
this.periodoAnnoList.update((lista) => [
|
||||
...lista,
|
||||
{
|
||||
id: periodo.id,
|
||||
nome: periodo.nome,
|
||||
inizioMese: periodo.inizioMese,
|
||||
fineMese: periodo.fineMese,
|
||||
stato: periodo.stato,
|
||||
},
|
||||
]);
|
||||
this.periodoQuery.set('');
|
||||
},
|
||||
error: () => this.classificazioneErrore.set('Impossibile proporre il periodo.'),
|
||||
});
|
||||
}
|
||||
|
||||
addMateriale(chip: ChipOption): void {
|
||||
this.materialeList.update((lista) => [...lista, { id: chip.id, nome: chip.nome, proprieta: '' }]);
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
color: var(--color-periodo-text);
|
||||
}
|
||||
|
||||
.badge-attesa {
|
||||
cursor: default;
|
||||
font-size: 13px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.materiale-box {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -123,3 +129,79 @@
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.moderazione-box {
|
||||
margin-top: 32px;
|
||||
background: var(--color-attesa-bg);
|
||||
border: 1px solid var(--color-attesa-border);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.moderazione-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: var(--color-attesa-text);
|
||||
}
|
||||
|
||||
.moderazione-azioni {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.moderazione-textarea {
|
||||
width: 100%;
|
||||
min-height: 90px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.note-box {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.note-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.nota {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.nota-meta {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.nota-testo {
|
||||
font-size: 15px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.nota-elimina {
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<div class="branca-row">
|
||||
@for (branca of a.brancaList; track branca.id) {
|
||||
<span class="branca-dot" [style.background]="branca.colore" [title]="branca.nome"></span>
|
||||
@if (branca.stato === 'DA_APPROVARE') {
|
||||
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
|
||||
}
|
||||
}
|
||||
<span class="branca-nomi">{{ brancaNomi() }}</span>
|
||||
</div>
|
||||
@@ -13,10 +16,20 @@
|
||||
</div>
|
||||
<div class="chips-row">
|
||||
@for (categoria of a.categoriaList; track categoria.id) {
|
||||
<span class="chip chip--categoria">{{ categoria.nome }}</span>
|
||||
<span class="chip chip--categoria">
|
||||
{{ categoria.nome }}
|
||||
@if (categoria.stato === 'DA_APPROVARE') {
|
||||
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@for (periodo of a.periodoAnnoList; track periodo.id) {
|
||||
<span class="chip chip--periodo">{{ periodo.nome }}</span>
|
||||
<span class="chip chip--periodo">
|
||||
{{ periodo.nome }}
|
||||
@if (periodo.stato === 'DA_APPROVARE') {
|
||||
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -50,6 +63,43 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (puoModerare()) {
|
||||
<div class="moderazione-box">
|
||||
<div class="moderazione-title">Moderazione</div>
|
||||
<div class="moderazione-azioni">
|
||||
<button class="btn-primary" [disabled]="azioneInCorso()" (click)="approva()">✓ Approva e pubblica</button>
|
||||
</div>
|
||||
<textarea
|
||||
class="moderazione-textarea"
|
||||
placeholder="Scrivi una nota per l'autore: l'attività tornerà in bozza per essere sistemata..."
|
||||
[value]="testoCommento()"
|
||||
(input)="testoCommento.set($any($event.target).value)"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
[disabled]="azioneInCorso() || !testoCommento().trim()"
|
||||
(click)="commenta()"
|
||||
>
|
||||
Commenta e rimanda all'autore
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (a.noteList && a.noteList.length > 0) {
|
||||
<div class="note-box">
|
||||
<div class="note-title">Note di moderazione</div>
|
||||
@for (nota of a.noteList; track nota.id) {
|
||||
<div class="nota">
|
||||
<div class="nota-meta">{{ nota.autore }} · {{ formatNotaData(nota.dataCreazione) }}</div>
|
||||
<div class="nota-testo">{{ nota.testo }}</div>
|
||||
@if (puoCancellareNote()) {
|
||||
<button class="nota-elimina" (click)="eliminaNota(nota.id)">Elimina nota</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import Keycloak from 'keycloak-js';
|
||||
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
|
||||
|
||||
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
|
||||
import { Attivita, Paragrafo } from '../../core/models/attivita.model';
|
||||
import { AttivitaService } from '../../core/services/attivita';
|
||||
import { formatData, parseMarkdown, statoStyle } from '../../core/utils/stato-style';
|
||||
@@ -14,9 +17,13 @@ import { formatData, parseMarkdown, statoStyle } from '../../core/utils/stato-st
|
||||
export class Detail {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly attivitaService = inject(AttivitaService);
|
||||
private readonly keycloak = inject(Keycloak);
|
||||
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
|
||||
|
||||
readonly attivita = signal<Attivita | null>(null);
|
||||
readonly caricamento = signal(true);
|
||||
readonly testoCommento = signal('');
|
||||
readonly azioneInCorso = signal(false);
|
||||
|
||||
readonly dataModificaFmt = computed(() => formatData(this.attivita()?.dataModifica));
|
||||
readonly statoTextColor = computed(() => statoStyle(this.attivita()?.stato.id ?? 'BO').color);
|
||||
@@ -25,8 +32,26 @@ export class Detail {
|
||||
[...(this.attivita()?.paragrafoList ?? [])].sort((a, b) => a.ordine - b.ordine)
|
||||
);
|
||||
|
||||
readonly isModeratore = computed(() => {
|
||||
this.keycloakEvent();
|
||||
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
|
||||
});
|
||||
|
||||
readonly puoModerare = computed(
|
||||
() => this.isModeratore() && this.attivita()?.stato.id === 'IA',
|
||||
);
|
||||
|
||||
readonly puoCancellareNote = computed(
|
||||
() => this.isModeratore() && this.attivita()?.stato.id === 'PU',
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.caricaAttivita();
|
||||
}
|
||||
|
||||
private caricaAttivita(): void {
|
||||
const idAttivita = Number(this.route.snapshot.paramMap.get('idAttivita'));
|
||||
this.caricamento.set(true);
|
||||
this.attivitaService.getOne(idAttivita).subscribe({
|
||||
next: (attivita) => {
|
||||
this.attivita.set(attivita);
|
||||
@@ -43,4 +68,42 @@ export class Detail {
|
||||
tokens(paragrafo: Paragrafo) {
|
||||
return parseMarkdown(paragrafo.corpo);
|
||||
}
|
||||
|
||||
formatNotaData(data: string): string {
|
||||
return formatData(data);
|
||||
}
|
||||
|
||||
approva(): void {
|
||||
const attivita = this.attivita();
|
||||
if (!attivita?.id || this.azioneInCorso()) return;
|
||||
|
||||
this.azioneInCorso.set(true);
|
||||
this.attivitaService.approva(attivita.id).subscribe({
|
||||
next: () => {
|
||||
this.azioneInCorso.set(false);
|
||||
this.caricaAttivita();
|
||||
},
|
||||
error: () => this.azioneInCorso.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
commenta(): void {
|
||||
const attivita = this.attivita();
|
||||
const testo = this.testoCommento().trim();
|
||||
if (!attivita?.id || !testo || this.azioneInCorso()) return;
|
||||
|
||||
this.azioneInCorso.set(true);
|
||||
this.attivitaService.commenta(attivita.id, testo).subscribe({
|
||||
next: () => {
|
||||
this.azioneInCorso.set(false);
|
||||
this.testoCommento.set('');
|
||||
this.caricaAttivita();
|
||||
},
|
||||
error: () => this.azioneInCorso.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
eliminaNota(idNota: number): void {
|
||||
this.attivitaService.eliminaNota(idNota).subscribe(() => this.caricaAttivita());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.approvazione-box {
|
||||
background: var(--color-attesa-bg);
|
||||
border: 1px solid var(--color-attesa-border);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.approvazione-title {
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
color: var(--color-attesa-text);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
<div class="page">
|
||||
@if (isModeratore() && daApprovare().length > 0) {
|
||||
<div class="approvazione-box">
|
||||
<div class="approvazione-title">⏳ Attività da approvare ({{ daApprovare().length }})</div>
|
||||
<div class="grid">
|
||||
@for (attivita of daApprovare(); track attivita.id) {
|
||||
<app-activity-card [attivita]="attivita" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Attività pubblicate</h1>
|
||||
<a class="search-bar" routerLink="/ricerca">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import Keycloak from 'keycloak-js';
|
||||
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
|
||||
|
||||
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
|
||||
import { Attivita } from '../../core/models/attivita.model';
|
||||
import { AttivitaService } from '../../core/services/attivita';
|
||||
import { ActivityCard } from '../../shared/activity-card/activity-card';
|
||||
@@ -13,9 +16,17 @@ import { ActivityCard } from '../../shared/activity-card/activity-card';
|
||||
})
|
||||
export class Home {
|
||||
private readonly attivitaService = inject(AttivitaService);
|
||||
private readonly keycloak = inject(Keycloak);
|
||||
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
|
||||
|
||||
readonly attivitaList = signal<Attivita[]>([]);
|
||||
readonly caricamento = signal(true);
|
||||
readonly daApprovare = signal<Attivita[]>([]);
|
||||
|
||||
readonly isModeratore = computed(() => {
|
||||
this.keycloakEvent();
|
||||
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.attivitaService.getListHome().subscribe({
|
||||
@@ -25,5 +36,16 @@ export class Home {
|
||||
},
|
||||
error: () => this.caricamento.set(false),
|
||||
});
|
||||
|
||||
// Il ruolo moderatore/admin arriva dal token Keycloak in modo asincrono: quando diventa
|
||||
// disponibile carichiamo la lista delle attività in attesa di approvazione da mostrare
|
||||
// in cima alla home.
|
||||
effect(() => {
|
||||
if (this.isModeratore()) {
|
||||
this.attivitaService.getListModerazione().subscribe((lista) => this.daApprovare.set(lista));
|
||||
} else {
|
||||
this.daApprovare.set([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,24 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.riga-badge {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.riga-note {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.stato-options {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
<div class="riga-info">
|
||||
<a class="riga-titolo" [routerLink]="['/attivita/dettaglio', attivita.id]">{{ attivita.nome }}</a>
|
||||
<div class="riga-data">Aggiornata il {{ dataModificaFmt(attivita) }}</div>
|
||||
@if (isInAttesa(attivita)) {
|
||||
<div class="riga-badge" [style.color]="stileStato('IA').color" [style.background]="stileStato('IA').bg" [style.border-color]="stileStato('IA').border">
|
||||
In attesa di approvazione
|
||||
</div>
|
||||
}
|
||||
@if (attivita.noteList && attivita.noteList.length > 0) {
|
||||
<a class="riga-note" [routerLink]="['/attivita/dettaglio', attivita.id]">📝 Note del moderatore</a>
|
||||
}
|
||||
</div>
|
||||
<div class="stato-options">
|
||||
@for (stato of stati; track stato.id) {
|
||||
|
||||
@@ -45,6 +45,10 @@ export class MyActivity {
|
||||
return attivita.stato.id === stato.id;
|
||||
}
|
||||
|
||||
isInAttesa(attivita: Attivita): boolean {
|
||||
return attivita.stato.id === 'IA';
|
||||
}
|
||||
|
||||
cambiaStato(attivita: Attivita, stato: Stato): void {
|
||||
if (!attivita.id || attivita.stato.id === stato.id) {
|
||||
return;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
.profile-page {
|
||||
max-width: 520px;
|
||||
padding-top: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
margin: 0 auto 18px;
|
||||
}
|
||||
|
||||
.titolo {
|
||||
font-size: 24px;
|
||||
margin: 0 0 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sottotitolo {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<div class="page profile-page">
|
||||
<div class="avatar">👤</div>
|
||||
<h1 class="titolo">Il tuo profilo</h1>
|
||||
<p class="sottotitolo">
|
||||
La gestione account arriverà con l'autenticazione. Per ora, accedi alle tue attività qui sotto.
|
||||
</p>
|
||||
<a class="btn-primary" routerLink="/mie-attivita">Vai a Le mie attività</a>
|
||||
</div>
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
imports: [RouterLink],
|
||||
templateUrl: './profile.html',
|
||||
styleUrl: './profile.css',
|
||||
})
|
||||
export class Profile {}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { catchError, debounceTime, distinctUntilChanged, of, switchMap } from 'rxjs';
|
||||
|
||||
import { Attivita } from '../../core/models/attivita.model';
|
||||
import { AutocompleteGroup, GruppoFiltro, SearchObjectDto } from '../../core/models/search.model';
|
||||
@@ -7,6 +9,8 @@ import { AutocompleteService } from '../../core/services/autocomplete';
|
||||
import { NavigationService } from '../../core/services/navigation';
|
||||
import { ActivityCard } from '../../shared/activity-card/activity-card';
|
||||
|
||||
const AUTOCOMPLETE_DEBOUNCE_MS = 300;
|
||||
|
||||
@Component({
|
||||
selector: 'app-search',
|
||||
imports: [ActivityCard],
|
||||
@@ -38,19 +42,26 @@ export class Search {
|
||||
error: () => this.results.set([]),
|
||||
});
|
||||
});
|
||||
|
||||
// Aspetta che l'utente smetta di scrivere per AUTOCOMPLETE_DEBOUNCE_MS prima di
|
||||
// interrogare il backend, invece di chiamarlo ad ogni singola lettera digitata.
|
||||
toObservable(this.searchQuery)
|
||||
.pipe(
|
||||
debounceTime(AUTOCOMPLETE_DEBOUNCE_MS),
|
||||
distinctUntilChanged(),
|
||||
switchMap((query) => {
|
||||
if (!query.trim()) {
|
||||
return of([]);
|
||||
}
|
||||
return this.autocompleteService.search(query).pipe(catchError(() => of([])));
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe((groups) => this.suggestionGroups.set(groups));
|
||||
}
|
||||
|
||||
onQueryChange(event: Event): void {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
this.searchQuery.set(value);
|
||||
if (!value.trim()) {
|
||||
this.suggestionGroups.set([]);
|
||||
return;
|
||||
}
|
||||
this.autocompleteService.search(value).subscribe({
|
||||
next: (groups) => this.suggestionGroups.set(groups),
|
||||
error: () => this.suggestionGroups.set([]),
|
||||
});
|
||||
this.searchQuery.set((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
onQueryKeyDown(event: KeyboardEvent): void {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
cursor: pointer;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tab--attivo {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-riga {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.text-input--sm {
|
||||
margin-top: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-azioni {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.errore-inline {
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.lista {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.riga {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.riga-info {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.riga-titolo {
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.riga-data {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.riga-azioni {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.riga-modifica {
|
||||
cursor: pointer;
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.riga-elimina {
|
||||
cursor: pointer;
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.proposte-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.proposte-titolo {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-attesa {
|
||||
cursor: default;
|
||||
font-size: 14px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title page-title--sm">Tassonomie</h1>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab" [class.tab--attivo]="tab() === 'branca'" (click)="setTab('branca')">Branca</div>
|
||||
<div class="tab" [class.tab--attivo]="tab() === 'categoria'" (click)="setTab('categoria')">Categoria</div>
|
||||
<div class="tab" [class.tab--attivo]="tab() === 'periodoAnno'" (click)="setTab('periodoAnno')">Periodo dell'anno</div>
|
||||
</div>
|
||||
|
||||
@if (caricamento()) {
|
||||
<div class="empty-state">Caricamento...</div>
|
||||
} @else if (erroreCaricamento()) {
|
||||
<div class="empty-state">{{ erroreCaricamento() }}</div>
|
||||
} @else {
|
||||
@if (tab() === 'branca') {
|
||||
@if (brancaProposte().length > 0) {
|
||||
<div class="proposte-box">
|
||||
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
|
||||
@for (branca of brancaProposte(); track branca.id) {
|
||||
<div class="riga">
|
||||
<div class="riga-info">
|
||||
<div class="riga-titolo">
|
||||
{{ branca.nome }}
|
||||
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
|
||||
</div>
|
||||
@if (approvazioneErroreId() === branca.id) {
|
||||
<div class="errore-inline">{{ approvazioneErroreMsg() }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="riga-azioni">
|
||||
<div class="riga-modifica" (click)="approvaBranca(branca)">Approva</div>
|
||||
<div class="riga-elimina" (click)="rifiutaBranca(branca)">Rifiuta</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="form-card">
|
||||
<div class="form-riga">
|
||||
<label class="field">
|
||||
Nome
|
||||
<input class="text-input text-input--sm" type="text" [value]="brancaNome()" (input)="brancaNome.set($any($event.target).value)" placeholder="Es. Lupetti" />
|
||||
</label>
|
||||
<label class="field">
|
||||
Inizio età
|
||||
<input class="text-input text-input--sm" type="number" [value]="brancaInizioEta() ?? ''" (input)="brancaInizioEta.set(numeroONull($any($event.target).value))" />
|
||||
</label>
|
||||
<label class="field">
|
||||
Fine età
|
||||
<input class="text-input text-input--sm" type="number" [value]="brancaFineEta() ?? ''" (input)="brancaFineEta.set(numeroONull($any($event.target).value))" />
|
||||
</label>
|
||||
<label class="field">
|
||||
Colore
|
||||
<input class="text-input text-input--sm" type="text" [value]="brancaColore() ?? ''" (input)="brancaColore.set($any($event.target).value)" placeholder="Es. #f4a300" />
|
||||
</label>
|
||||
</div>
|
||||
@if (brancaSalvataggioErrore()) {
|
||||
<div class="errore-inline">{{ brancaSalvataggioErrore() }}</div>
|
||||
}
|
||||
<div class="form-azioni">
|
||||
<div class="btn-primary" (click)="salvaBranca()">{{ brancaEditId() ? 'Salva modifiche' : '+ Aggiungi branca' }}</div>
|
||||
@if (brancaEditId()) {
|
||||
<div class="btn-secondary" (click)="annullaBranca()">Annulla</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (brancaConfermate().length > 0) {
|
||||
<div class="lista">
|
||||
@for (branca of brancaConfermate(); track branca.id) {
|
||||
<div class="riga">
|
||||
<div class="riga-info">
|
||||
<div class="riga-titolo">{{ branca.nome }}</div>
|
||||
<div class="riga-data">
|
||||
@if (branca.inizioEta !== null || branca.fineEta !== null) {
|
||||
Età {{ branca.inizioEta ?? '?' }}-{{ branca.fineEta ?? '?' }}
|
||||
}
|
||||
@if (branca.colore) {
|
||||
· {{ branca.colore }}
|
||||
}
|
||||
</div>
|
||||
@if (eliminaErroreId() === branca.id) {
|
||||
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="riga-azioni">
|
||||
<div class="riga-modifica" (click)="modificaBranca(branca)">Modifica</div>
|
||||
<div class="riga-elimina" (click)="eliminaBranca(branca)">Elimina</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">Nessuna branca presente</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (tab() === 'categoria') {
|
||||
@if (categoriaProposte().length > 0) {
|
||||
<div class="proposte-box">
|
||||
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
|
||||
@for (categoria of categoriaProposte(); track categoria.id) {
|
||||
<div class="riga">
|
||||
<div class="riga-info">
|
||||
<div class="riga-titolo">
|
||||
{{ categoria.nome }}
|
||||
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
|
||||
</div>
|
||||
@if (approvazioneErroreId() === categoria.id) {
|
||||
<div class="errore-inline">{{ approvazioneErroreMsg() }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="riga-azioni">
|
||||
<div class="riga-modifica" (click)="approvaCategoria(categoria)">Approva</div>
|
||||
<div class="riga-elimina" (click)="rifiutaCategoria(categoria)">Rifiuta</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="form-card">
|
||||
<div class="form-riga">
|
||||
<label class="field">
|
||||
Nome
|
||||
<input class="text-input text-input--sm" type="text" [value]="categoriaNome()" (input)="categoriaNome.set($any($event.target).value)" placeholder="Es. Giochi" />
|
||||
</label>
|
||||
<label class="field">
|
||||
Tipo
|
||||
<select class="text-input text-input--sm" [value]="categoriaTipoId()" (change)="categoriaTipoId.set($any($event.target).value)">
|
||||
<option value="" disabled>Seleziona un tipo</option>
|
||||
@for (tipo of tipoCategoriaList(); track tipo.id) {
|
||||
<option [value]="tipo.id">{{ tipo.nome }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
Categoria padre
|
||||
<select class="text-input text-input--sm" [value]="categoriaPadreId() ?? ''" (change)="categoriaPadreId.set(numeroONull($any($event.target).value))">
|
||||
<option value="">Nessuna</option>
|
||||
@for (categoria of categoriaPadreOpzioni(); track categoria.id) {
|
||||
<option [value]="categoria.id">{{ categoria.nome }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@if (categoriaSalvataggioErrore()) {
|
||||
<div class="errore-inline">{{ categoriaSalvataggioErrore() }}</div>
|
||||
}
|
||||
<div class="form-azioni">
|
||||
<div class="btn-primary" (click)="salvaCategoria()">{{ categoriaEditId() ? 'Salva modifiche' : '+ Aggiungi categoria' }}</div>
|
||||
@if (categoriaEditId()) {
|
||||
<div class="btn-secondary" (click)="annullaCategoria()">Annulla</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (categoriaConfermate().length > 0) {
|
||||
<div class="lista">
|
||||
@for (categoria of categoriaConfermate(); track categoria.id) {
|
||||
<div class="riga">
|
||||
<div class="riga-info">
|
||||
<div class="riga-titolo">{{ categoria.nome }}</div>
|
||||
<div class="riga-data">
|
||||
{{ tipoCategoriaNome(categoria.tipoId) }}
|
||||
@if (categoriaPadreNome(categoria.padreId); as padreNome) {
|
||||
· figlia di {{ padreNome }}
|
||||
}
|
||||
</div>
|
||||
@if (eliminaErroreId() === categoria.id) {
|
||||
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="riga-azioni">
|
||||
<div class="riga-modifica" (click)="modificaCategoria(categoria)">Modifica</div>
|
||||
<div class="riga-elimina" (click)="eliminaCategoria(categoria)">Elimina</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">Nessuna categoria presente</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (tab() === 'periodoAnno') {
|
||||
@if (periodoProposti().length > 0) {
|
||||
<div class="proposte-box">
|
||||
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
|
||||
@for (periodo of periodoProposti(); track periodo.id) {
|
||||
<div class="riga">
|
||||
<div class="riga-info">
|
||||
<div class="riga-titolo">
|
||||
{{ periodo.nome }}
|
||||
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
|
||||
</div>
|
||||
@if (approvazioneErroreId() === periodo.id) {
|
||||
<div class="errore-inline">{{ approvazioneErroreMsg() }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="riga-azioni">
|
||||
<div class="riga-modifica" (click)="approvaPeriodo(periodo)">Approva</div>
|
||||
<div class="riga-elimina" (click)="rifiutaPeriodo(periodo)">Rifiuta</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="form-card">
|
||||
<div class="form-riga">
|
||||
<label class="field">
|
||||
Nome
|
||||
<input class="text-input text-input--sm" type="text" [value]="periodoNome()" (input)="periodoNome.set($any($event.target).value)" placeholder="Es. Estate" />
|
||||
</label>
|
||||
<label class="field">
|
||||
Mese inizio
|
||||
<input class="text-input text-input--sm" type="number" min="1" max="12" [value]="periodoInizioMese() ?? ''" (input)="periodoInizioMese.set(numeroONull($any($event.target).value))" />
|
||||
</label>
|
||||
<label class="field">
|
||||
Mese fine
|
||||
<input class="text-input text-input--sm" type="number" min="1" max="12" [value]="periodoFineMese() ?? ''" (input)="periodoFineMese.set(numeroONull($any($event.target).value))" />
|
||||
</label>
|
||||
</div>
|
||||
@if (periodoSalvataggioErrore()) {
|
||||
<div class="errore-inline">{{ periodoSalvataggioErrore() }}</div>
|
||||
}
|
||||
<div class="form-azioni">
|
||||
<div class="btn-primary" (click)="salvaPeriodo()">{{ periodoEditId() ? 'Salva modifiche' : '+ Aggiungi periodo' }}</div>
|
||||
@if (periodoEditId()) {
|
||||
<div class="btn-secondary" (click)="annullaPeriodo()">Annulla</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (periodoConfermati().length > 0) {
|
||||
<div class="lista">
|
||||
@for (periodo of periodoConfermati(); track periodo.id) {
|
||||
<div class="riga">
|
||||
<div class="riga-info">
|
||||
<div class="riga-titolo">{{ periodo.nome }}</div>
|
||||
<div class="riga-data">
|
||||
@if (periodo.inizioMese !== null || periodo.fineMese !== null) {
|
||||
Mese {{ periodo.inizioMese ?? '?' }}-{{ periodo.fineMese ?? '?' }}
|
||||
}
|
||||
</div>
|
||||
@if (eliminaErroreId() === periodo.id) {
|
||||
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="riga-azioni">
|
||||
<div class="riga-modifica" (click)="modificaPeriodo(periodo)">Modifica</div>
|
||||
<div class="riga-elimina" (click)="eliminaPeriodo(periodo)">Elimina</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">Nessun periodo presente</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,375 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { TassonomieService } from '../../core/services/tassonomie';
|
||||
import {
|
||||
Branca,
|
||||
Categoria,
|
||||
PeriodoAnno,
|
||||
TipoCategoria,
|
||||
} from '../../core/models/tassonomia.model';
|
||||
|
||||
type Tab = 'branca' | 'categoria' | 'periodoAnno';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tassonomie',
|
||||
templateUrl: './tassonomie.html',
|
||||
styleUrl: './tassonomie.css',
|
||||
})
|
||||
export class Tassonomie {
|
||||
private readonly tassonomieService = inject(TassonomieService);
|
||||
|
||||
readonly tab = signal<Tab>('branca');
|
||||
readonly caricamento = signal(true);
|
||||
readonly erroreCaricamento = signal<string | null>(null);
|
||||
|
||||
readonly brancaList = signal<Branca[]>([]);
|
||||
readonly categoriaList = signal<Categoria[]>([]);
|
||||
readonly tipoCategoriaList = signal<TipoCategoria[]>([]);
|
||||
readonly periodoAnnoList = signal<PeriodoAnno[]>([]);
|
||||
|
||||
readonly brancaEditId = signal<number | null>(null);
|
||||
readonly brancaNome = signal('');
|
||||
readonly brancaInizioEta = signal<number | null>(null);
|
||||
readonly brancaFineEta = signal<number | null>(null);
|
||||
readonly brancaColore = signal<string | null>(null);
|
||||
readonly brancaSalvataggioErrore = signal<string | null>(null);
|
||||
|
||||
readonly categoriaEditId = signal<number | null>(null);
|
||||
readonly categoriaNome = signal('');
|
||||
readonly categoriaPadreId = signal<number | null>(null);
|
||||
readonly categoriaTipoId = signal('');
|
||||
readonly categoriaSalvataggioErrore = signal<string | null>(null);
|
||||
|
||||
readonly periodoEditId = signal<number | null>(null);
|
||||
readonly periodoNome = signal('');
|
||||
readonly periodoInizioMese = signal<number | null>(null);
|
||||
readonly periodoFineMese = signal<number | null>(null);
|
||||
readonly periodoSalvataggioErrore = signal<string | null>(null);
|
||||
|
||||
readonly eliminaErroreId = signal<number | null>(null);
|
||||
readonly eliminaErroreMsg = signal<string | null>(null);
|
||||
|
||||
readonly categoriaPadreOpzioni = computed(() =>
|
||||
this.categoriaList().filter((c) => c.id !== this.categoriaEditId()),
|
||||
);
|
||||
|
||||
readonly brancaConfermate = computed(() => this.brancaList().filter((b) => b.stato === 'CONFERMATA'));
|
||||
readonly brancaProposte = computed(() => this.brancaList().filter((b) => b.stato === 'DA_APPROVARE'));
|
||||
readonly categoriaConfermate = computed(() => this.categoriaList().filter((c) => c.stato === 'CONFERMATA'));
|
||||
readonly categoriaProposte = computed(() => this.categoriaList().filter((c) => c.stato === 'DA_APPROVARE'));
|
||||
readonly periodoConfermati = computed(() => this.periodoAnnoList().filter((p) => p.stato === 'CONFERMATA'));
|
||||
readonly periodoProposti = computed(() => this.periodoAnnoList().filter((p) => p.stato === 'DA_APPROVARE'));
|
||||
|
||||
readonly approvazioneErroreId = signal<number | null>(null);
|
||||
readonly approvazioneErroreMsg = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.caricaTutto();
|
||||
}
|
||||
|
||||
setTab(tab: Tab): void {
|
||||
this.tab.set(tab);
|
||||
this.annullaBranca();
|
||||
this.annullaCategoria();
|
||||
this.annullaPeriodo();
|
||||
}
|
||||
|
||||
private async caricaTutto(): Promise<void> {
|
||||
this.caricamento.set(true);
|
||||
this.erroreCaricamento.set(null);
|
||||
|
||||
try {
|
||||
const [branche, categorie, tipiCategoria, periodi] = await Promise.all([
|
||||
firstValueFrom(this.tassonomieService.listBranca()),
|
||||
firstValueFrom(this.tassonomieService.listCategoria()),
|
||||
firstValueFrom(this.tassonomieService.listTipoCategoria()),
|
||||
firstValueFrom(this.tassonomieService.listPeriodoAnno()),
|
||||
]);
|
||||
this.brancaList.set(branche);
|
||||
this.categoriaList.set(categorie);
|
||||
this.tipoCategoriaList.set(tipiCategoria);
|
||||
this.periodoAnnoList.set(periodi);
|
||||
} catch {
|
||||
this.erroreCaricamento.set('Impossibile caricare le tassonomie. Riprova più tardi.');
|
||||
} finally {
|
||||
this.caricamento.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
tipoCategoriaNome(tipoId: string): string {
|
||||
return this.tipoCategoriaList().find((t) => t.id === tipoId)?.nome ?? tipoId;
|
||||
}
|
||||
|
||||
categoriaPadreNome(padreId: number | null): string | null {
|
||||
if (padreId === null) {
|
||||
return null;
|
||||
}
|
||||
return this.categoriaList().find((c) => c.id === padreId)?.nome ?? null;
|
||||
}
|
||||
|
||||
modificaBranca(branca: Branca): void {
|
||||
this.brancaEditId.set(branca.id);
|
||||
this.brancaNome.set(branca.nome);
|
||||
this.brancaInizioEta.set(branca.inizioEta);
|
||||
this.brancaFineEta.set(branca.fineEta);
|
||||
this.brancaColore.set(branca.colore);
|
||||
this.brancaSalvataggioErrore.set(null);
|
||||
}
|
||||
|
||||
annullaBranca(): void {
|
||||
this.brancaEditId.set(null);
|
||||
this.brancaNome.set('');
|
||||
this.brancaInizioEta.set(null);
|
||||
this.brancaFineEta.set(null);
|
||||
this.brancaColore.set(null);
|
||||
this.brancaSalvataggioErrore.set(null);
|
||||
}
|
||||
|
||||
async salvaBranca(): Promise<void> {
|
||||
const nome = this.brancaNome().trim();
|
||||
if (!nome) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = {
|
||||
nome,
|
||||
inizioEta: this.brancaInizioEta(),
|
||||
fineEta: this.brancaFineEta(),
|
||||
colore: this.brancaColore() || null,
|
||||
};
|
||||
|
||||
this.brancaSalvataggioErrore.set(null);
|
||||
try {
|
||||
const editId = this.brancaEditId();
|
||||
const salvata = editId
|
||||
? await firstValueFrom(this.tassonomieService.updateBranca(editId, input))
|
||||
: await firstValueFrom(this.tassonomieService.createBranca(input));
|
||||
|
||||
this.brancaList.update((lista) => {
|
||||
const senzaVecchia = lista.filter((b) => b.id !== salvata.id);
|
||||
return [...senzaVecchia, salvata].sort((a, b) => a.nome.localeCompare(b.nome));
|
||||
});
|
||||
this.annullaBranca();
|
||||
} catch {
|
||||
this.brancaSalvataggioErrore.set('Impossibile salvare la branca.');
|
||||
}
|
||||
}
|
||||
|
||||
async eliminaBranca(branca: Branca): Promise<void> {
|
||||
this.eliminaErroreId.set(null);
|
||||
this.eliminaErroreMsg.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.tassonomieService.deleteBranca(branca.id));
|
||||
this.brancaList.update((lista) => lista.filter((b) => b.id !== branca.id));
|
||||
} catch (err) {
|
||||
this.eliminaErroreId.set(branca.id);
|
||||
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
async approvaBranca(branca: Branca): Promise<void> {
|
||||
this.approvazioneErroreId.set(null);
|
||||
this.approvazioneErroreMsg.set(null);
|
||||
try {
|
||||
const approvata = await firstValueFrom(this.tassonomieService.approvaBranca(branca.id));
|
||||
this.brancaList.update((lista) => lista.map((b) => (b.id === approvata.id ? approvata : b)));
|
||||
} catch (err) {
|
||||
this.approvazioneErroreId.set(branca.id);
|
||||
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
async rifiutaBranca(branca: Branca): Promise<void> {
|
||||
this.approvazioneErroreId.set(null);
|
||||
this.approvazioneErroreMsg.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.tassonomieService.rifiutaBranca(branca.id));
|
||||
this.brancaList.update((lista) => lista.filter((b) => b.id !== branca.id));
|
||||
} catch (err) {
|
||||
this.approvazioneErroreId.set(branca.id);
|
||||
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
modificaCategoria(categoria: Categoria): void {
|
||||
this.categoriaEditId.set(categoria.id);
|
||||
this.categoriaNome.set(categoria.nome);
|
||||
this.categoriaPadreId.set(categoria.padreId);
|
||||
this.categoriaTipoId.set(categoria.tipoId);
|
||||
this.categoriaSalvataggioErrore.set(null);
|
||||
}
|
||||
|
||||
annullaCategoria(): void {
|
||||
this.categoriaEditId.set(null);
|
||||
this.categoriaNome.set('');
|
||||
this.categoriaPadreId.set(null);
|
||||
this.categoriaTipoId.set('');
|
||||
this.categoriaSalvataggioErrore.set(null);
|
||||
}
|
||||
|
||||
async salvaCategoria(): Promise<void> {
|
||||
const nome = this.categoriaNome().trim();
|
||||
const tipoId = this.categoriaTipoId();
|
||||
if (!nome || !tipoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = {
|
||||
nome,
|
||||
padreId: this.categoriaPadreId(),
|
||||
tipoId,
|
||||
};
|
||||
|
||||
this.categoriaSalvataggioErrore.set(null);
|
||||
try {
|
||||
const editId = this.categoriaEditId();
|
||||
const salvata = editId
|
||||
? await firstValueFrom(this.tassonomieService.updateCategoria(editId, input))
|
||||
: await firstValueFrom(this.tassonomieService.createCategoria(input));
|
||||
|
||||
this.categoriaList.update((lista) => {
|
||||
const senzaVecchia = lista.filter((c) => c.id !== salvata.id);
|
||||
return [...senzaVecchia, salvata].sort((a, b) => a.nome.localeCompare(b.nome));
|
||||
});
|
||||
this.annullaCategoria();
|
||||
} catch {
|
||||
this.categoriaSalvataggioErrore.set('Impossibile salvare la categoria.');
|
||||
}
|
||||
}
|
||||
|
||||
async eliminaCategoria(categoria: Categoria): Promise<void> {
|
||||
this.eliminaErroreId.set(null);
|
||||
this.eliminaErroreMsg.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.tassonomieService.deleteCategoria(categoria.id));
|
||||
this.categoriaList.update((lista) => lista.filter((c) => c.id !== categoria.id));
|
||||
} catch (err) {
|
||||
this.eliminaErroreId.set(categoria.id);
|
||||
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
async approvaCategoria(categoria: Categoria): Promise<void> {
|
||||
this.approvazioneErroreId.set(null);
|
||||
this.approvazioneErroreMsg.set(null);
|
||||
try {
|
||||
const approvata = await firstValueFrom(this.tassonomieService.approvaCategoria(categoria.id));
|
||||
this.categoriaList.update((lista) => lista.map((c) => (c.id === approvata.id ? approvata : c)));
|
||||
} catch (err) {
|
||||
this.approvazioneErroreId.set(categoria.id);
|
||||
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
async rifiutaCategoria(categoria: Categoria): Promise<void> {
|
||||
this.approvazioneErroreId.set(null);
|
||||
this.approvazioneErroreMsg.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.tassonomieService.rifiutaCategoria(categoria.id));
|
||||
this.categoriaList.update((lista) => lista.filter((c) => c.id !== categoria.id));
|
||||
} catch (err) {
|
||||
this.approvazioneErroreId.set(categoria.id);
|
||||
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
modificaPeriodo(periodo: PeriodoAnno): void {
|
||||
this.periodoEditId.set(periodo.id);
|
||||
this.periodoNome.set(periodo.nome);
|
||||
this.periodoInizioMese.set(periodo.inizioMese);
|
||||
this.periodoFineMese.set(periodo.fineMese);
|
||||
this.periodoSalvataggioErrore.set(null);
|
||||
}
|
||||
|
||||
annullaPeriodo(): void {
|
||||
this.periodoEditId.set(null);
|
||||
this.periodoNome.set('');
|
||||
this.periodoInizioMese.set(null);
|
||||
this.periodoFineMese.set(null);
|
||||
this.periodoSalvataggioErrore.set(null);
|
||||
}
|
||||
|
||||
async salvaPeriodo(): Promise<void> {
|
||||
const nome = this.periodoNome().trim();
|
||||
if (!nome) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = {
|
||||
nome,
|
||||
inizioMese: this.periodoInizioMese(),
|
||||
fineMese: this.periodoFineMese(),
|
||||
};
|
||||
|
||||
this.periodoSalvataggioErrore.set(null);
|
||||
try {
|
||||
const editId = this.periodoEditId();
|
||||
const salvato = editId
|
||||
? await firstValueFrom(this.tassonomieService.updatePeriodoAnno(editId, input))
|
||||
: await firstValueFrom(this.tassonomieService.createPeriodoAnno(input));
|
||||
|
||||
this.periodoAnnoList.update((lista) => {
|
||||
const senzaVecchia = lista.filter((p) => p.id !== salvato.id);
|
||||
return [...senzaVecchia, salvato].sort((a, b) => a.nome.localeCompare(b.nome));
|
||||
});
|
||||
this.annullaPeriodo();
|
||||
} catch {
|
||||
this.periodoSalvataggioErrore.set('Impossibile salvare il periodo.');
|
||||
}
|
||||
}
|
||||
|
||||
async eliminaPeriodo(periodo: PeriodoAnno): Promise<void> {
|
||||
this.eliminaErroreId.set(null);
|
||||
this.eliminaErroreMsg.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.tassonomieService.deletePeriodoAnno(periodo.id));
|
||||
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== periodo.id));
|
||||
} catch (err) {
|
||||
this.eliminaErroreId.set(periodo.id);
|
||||
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
async approvaPeriodo(periodo: PeriodoAnno): Promise<void> {
|
||||
this.approvazioneErroreId.set(null);
|
||||
this.approvazioneErroreMsg.set(null);
|
||||
try {
|
||||
const approvato = await firstValueFrom(this.tassonomieService.approvaPeriodoAnno(periodo.id));
|
||||
this.periodoAnnoList.update((lista) => lista.map((p) => (p.id === approvato.id ? approvato : p)));
|
||||
} catch (err) {
|
||||
this.approvazioneErroreId.set(periodo.id);
|
||||
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
async rifiutaPeriodo(periodo: PeriodoAnno): Promise<void> {
|
||||
this.approvazioneErroreId.set(null);
|
||||
this.approvazioneErroreMsg.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.tassonomieService.rifiutaPeriodoAnno(periodo.id));
|
||||
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== periodo.id));
|
||||
} catch (err) {
|
||||
this.approvazioneErroreId.set(periodo.id);
|
||||
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
private estraiMessaggioErrore(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'error' in err) {
|
||||
const body = (err as { error?: unknown }).error;
|
||||
if (body && typeof body === 'object' && 'message' in body && typeof (body as { message?: unknown }).message === 'string') {
|
||||
return (body as { message: string }).message;
|
||||
}
|
||||
}
|
||||
return 'Impossibile eliminare l\'elemento.';
|
||||
}
|
||||
|
||||
numeroONull(value: string): number | null {
|
||||
if (value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,17 @@
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.suggestion-item--create {
|
||||
color: var(--color-primary-text);
|
||||
font-weight: 600;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.chip-badge {
|
||||
cursor: default;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
[value]="query()"
|
||||
(input)="onQueryInput($event)"
|
||||
/>
|
||||
@if (suggestions().length > 0) {
|
||||
@if (suggestions().length > 0 || showCreateOption()) {
|
||||
<div class="suggestions">
|
||||
@for (suggestion of suggestions(); track suggestion.id) {
|
||||
<div class="suggestion-item" (click)="select.emit(suggestion)">{{ suggestion.nome }}</div>
|
||||
}
|
||||
@if (showCreateOption()) {
|
||||
<div class="suggestion-item suggestion-item--create" (click)="onCreate()">
|
||||
+ Crea "{{ query() }}"
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -20,6 +25,9 @@
|
||||
@for (chip of chips(); track chip.id) {
|
||||
<div class="chip">
|
||||
<span>{{ chip.nome }}</span>
|
||||
@if (chip.stato === 'DA_APPROVARE') {
|
||||
<span class="chip-badge" title="In attesa di conferma">⚠️</span>
|
||||
}
|
||||
<span class="chip-remove" (click)="remove.emit(chip)">×</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, input, model, output } from '@angular/core';
|
||||
import { Component, computed, input, model, output } from '@angular/core';
|
||||
|
||||
export interface ChipOption {
|
||||
id: number;
|
||||
nome: string;
|
||||
stato?: 'CONFERMATA' | 'DA_APPROVARE';
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -15,12 +16,33 @@ export class ChipField {
|
||||
readonly label = input.required<string>();
|
||||
readonly chips = input.required<ChipOption[]>();
|
||||
readonly suggestions = input<ChipOption[]>([]);
|
||||
readonly allowCreate = input(false);
|
||||
readonly query = model('');
|
||||
|
||||
readonly remove = output<ChipOption>();
|
||||
readonly select = output<ChipOption>();
|
||||
readonly create = output<string>();
|
||||
|
||||
readonly showCreateOption = computed(() => {
|
||||
const query = this.query().trim();
|
||||
if (!this.allowCreate() || !query) {
|
||||
return false;
|
||||
}
|
||||
const queryLower = query.toLowerCase();
|
||||
const giaPresente = [...this.suggestions(), ...this.chips()].some(
|
||||
(opzione) => opzione.nome.toLowerCase() === queryLower,
|
||||
);
|
||||
return !giaPresente;
|
||||
});
|
||||
|
||||
onQueryInput(event: Event): void {
|
||||
this.query.set((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
onCreate(): void {
|
||||
const nome = this.query().trim();
|
||||
if (nome) {
|
||||
this.create.emit(nome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,3 +97,119 @@
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notifiche {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.campanellina {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.campanellina:hover {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.campanellina-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-error, #d64545);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notifiche-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 19;
|
||||
}
|
||||
|
||||
.notifiche-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: 320px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-header);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.notifiche-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.notifiche-segna-tutte {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--color-primary-text-strong);
|
||||
}
|
||||
|
||||
.notifiche-vuoto {
|
||||
padding: 20px 14px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.notifica-item {
|
||||
cursor: pointer;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.notifica-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notifica-item:hover {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.notifica-item--non-letta {
|
||||
font-weight: 600;
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.notifica-item--non-letta::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-right: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<span class="brand-name">Scouthub</span>
|
||||
</a>
|
||||
<nav class="nav-items">
|
||||
@for (item of navItems; track item.path) {
|
||||
@for (item of navItems(); track item.path) {
|
||||
<a
|
||||
class="nav-item"
|
||||
[class.nav-item--active]="isActive(item.path)"
|
||||
@@ -28,6 +28,45 @@
|
||||
</nav>
|
||||
<div class="auth-section">
|
||||
@if (isAuthenticated()) {
|
||||
<div class="notifiche">
|
||||
<button
|
||||
type="button"
|
||||
class="campanellina"
|
||||
(click)="toggleCampanellina()"
|
||||
aria-label="Notifiche"
|
||||
>
|
||||
🔔
|
||||
@if (countNonLette() > 0) {
|
||||
<span class="campanellina-badge">{{ countBadge() }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (campanellinaAperta()) {
|
||||
<div class="notifiche-overlay" (click)="chiudiCampanellina()"></div>
|
||||
<div class="notifiche-dropdown">
|
||||
<div class="notifiche-header">
|
||||
<span>Notifiche</span>
|
||||
@if (countNonLette() > 0) {
|
||||
<span class="notifiche-segna-tutte" (click)="segnaTutteLette()">
|
||||
Segna tutte come lette
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (notifiche().length === 0) {
|
||||
<div class="notifiche-vuoto">Nessuna notifica</div>
|
||||
} @else {
|
||||
@for (notifica of notifiche(); track notifica.id) {
|
||||
<div
|
||||
class="notifica-item"
|
||||
[class.notifica-item--non-letta]="!notifica.letta"
|
||||
(click)="apriNotifica(notifica)"
|
||||
>
|
||||
<span class="notifica-messaggio">{{ notifica.messaggio }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<span class="auth-name">{{ displayName() }}</span>
|
||||
<div class="btn-secondary btn-secondary--sm" (click)="logout()">Esci</div>
|
||||
} @else {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { Location } from '@angular/common';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
|
||||
import { NavigationEnd, Router, RouterLink } from '@angular/router';
|
||||
import { filter } from 'rxjs';
|
||||
import Keycloak from 'keycloak-js';
|
||||
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
|
||||
|
||||
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
|
||||
import { Notifica } from '../../core/models/notifica.model';
|
||||
import { NotificheService } from '../../core/services/notifiche';
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const INTERVALLO_POLLING_MS = 30000;
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
imports: [RouterLink],
|
||||
@@ -21,9 +27,20 @@ export class Header {
|
||||
private readonly location = inject(Location);
|
||||
private readonly keycloak = inject(Keycloak);
|
||||
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
|
||||
private readonly notificheService = inject(NotificheService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly currentUrl = signal(this.router.url);
|
||||
|
||||
readonly notifiche = signal<Notifica[]>([]);
|
||||
readonly countNonLette = signal(0);
|
||||
readonly campanellinaAperta = signal(false);
|
||||
|
||||
readonly countBadge = computed(() => {
|
||||
const count = this.countNonLette();
|
||||
return count > 9 ? '9+' : String(count);
|
||||
});
|
||||
|
||||
readonly isAuthenticated = computed(() => {
|
||||
this.keycloakEvent();
|
||||
return !!this.keycloak.authenticated;
|
||||
@@ -40,17 +57,46 @@ export class Header {
|
||||
return url.startsWith('/attivita/dettaglio') || url.startsWith('/nuova-attivita') || url.startsWith('/modifica-attivita');
|
||||
});
|
||||
|
||||
readonly navItems: NavItem[] = [
|
||||
{ path: '/', label: 'Home' },
|
||||
{ path: '/ricerca', label: 'Cerca' },
|
||||
{ path: '/mie-attivita', label: 'Le mie attività' },
|
||||
{ path: '/profilo', label: 'Profilo' },
|
||||
];
|
||||
readonly isModeratore = computed(() => {
|
||||
this.keycloakEvent();
|
||||
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
|
||||
});
|
||||
|
||||
readonly navItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{ path: '/', label: 'Home' },
|
||||
{ path: '/ricerca', label: 'Cerca' },
|
||||
{ path: '/mie-attivita', label: 'Le mie attività' },
|
||||
];
|
||||
if (this.isModeratore()) {
|
||||
items.push({ path: '/tassonomie', label: 'Tassonomie' });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => {
|
||||
this.currentUrl.set(this.router.url);
|
||||
});
|
||||
|
||||
// Il polling parte/si ferma seguendo lo stato di autenticazione: non ha senso interrogare
|
||||
// l'endpoint privato delle notifiche per un utente non loggato.
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
effect(() => {
|
||||
if (this.isAuthenticated()) {
|
||||
this.aggiornaCountNonLette();
|
||||
intervalId = setInterval(() => this.aggiornaCountNonLette(), INTERVALLO_POLLING_MS);
|
||||
} else {
|
||||
this.notifiche.set([]);
|
||||
this.countNonLette.set(0);
|
||||
}
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
if (intervalId !== undefined) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isActive(path: string): boolean {
|
||||
@@ -68,4 +114,50 @@ export class Header {
|
||||
logout(): void {
|
||||
this.keycloak.logout({ redirectUri: window.location.origin + '/' });
|
||||
}
|
||||
|
||||
private aggiornaCountNonLette(): void {
|
||||
this.notificheService.getCountNonLette().subscribe({
|
||||
next: ({ count }) => this.countNonLette.set(count),
|
||||
});
|
||||
}
|
||||
|
||||
toggleCampanellina(): void {
|
||||
const apri = !this.campanellinaAperta();
|
||||
this.campanellinaAperta.set(apri);
|
||||
if (apri) {
|
||||
this.notificheService.getLista().subscribe({
|
||||
next: (lista) => this.notifiche.set(lista),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
chiudiCampanellina(): void {
|
||||
this.campanellinaAperta.set(false);
|
||||
}
|
||||
|
||||
apriNotifica(notifica: Notifica): void {
|
||||
if (!notifica.letta) {
|
||||
this.notificheService.segnaLetta(notifica.id).subscribe({
|
||||
next: () => {
|
||||
this.notifiche.update((lista) =>
|
||||
lista.map((n) => (n.id === notifica.id ? { ...n, letta: true } : n)),
|
||||
);
|
||||
this.countNonLette.update((count) => Math.max(0, count - 1));
|
||||
},
|
||||
});
|
||||
}
|
||||
this.campanellinaAperta.set(false);
|
||||
if (notifica.link) {
|
||||
this.router.navigateByUrl(notifica.link);
|
||||
}
|
||||
}
|
||||
|
||||
segnaTutteLette(): void {
|
||||
this.notificheService.segnaTutteLette().subscribe({
|
||||
next: () => {
|
||||
this.notifiche.update((lista) => lista.map((n) => ({ ...n, letta: true })));
|
||||
this.countNonLette.set(0);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
--color-periodo-border: oklch(80% 0.04 260);
|
||||
--color-periodo-text: oklch(35% 0.05 260);
|
||||
--color-danger: oklch(50% 0.15 25);
|
||||
--color-attesa-bg: oklch(93% 0.06 90);
|
||||
--color-attesa-border: oklch(80% 0.1 90);
|
||||
--color-attesa-text: oklch(40% 0.1 90);
|
||||
--color-neutral-bg: oklch(92% 0.005 60);
|
||||
--color-neutral-text: oklch(45% 0.02 55);
|
||||
--color-neutral-border: oklch(85% 0.01 60);
|
||||
|
||||
Reference in New Issue
Block a user