Add scouthub-attivita-fe

This commit is contained in:
Lorenzo Sanesi
2026-07-23 19:35:45 +02:00
parent 2ee7d402b6
commit cfd2de4ca3
61 changed files with 11437 additions and 0 deletions
@@ -0,0 +1,202 @@
.wizard-page {
max-width: 760px;
padding-bottom: 80px;
}
.titolo {
font-size: 26px;
margin: 0 0 6px;
font-weight: 700;
}
.steps {
display: flex;
gap: 6px;
margin: 18px 0 28px;
flex-wrap: wrap;
}
.step-tab {
cursor: pointer;
flex: 1;
min-width: 100px;
text-align: center;
padding: 10px 8px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
background: var(--color-surface);
color: var(--color-text);
}
.step-tab--attivo {
background: var(--color-primary);
color: #fff;
}
.step-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.step-content--gap {
gap: 24px;
}
.field {
font-weight: 700;
font-size: 14px;
}
.field--sm {
font-size: 13px;
font-weight: 600;
color: var(--color-text-muted);
}
.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-surface);
color: var(--color-text);
}
.text-input--sm {
margin-top: 4px;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
}
.stato-choices {
display: flex;
gap: 8px;
margin-top: 6px;
}
.stato-choice {
cursor: pointer;
padding: 9px 16px;
border-radius: 8px;
font-weight: 700;
font-size: 14px;
border: 1px solid var(--color-border);
}
.paragrafo-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 14px 16px;
}
.paragrafo-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.paragrafo-tipo {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--color-primary-text-strong);
}
.paragrafo-azioni {
display: flex;
gap: 6px;
}
.azione {
cursor: pointer;
padding: 2px 8px;
}
.azione--danger {
color: var(--color-danger);
}
.formattazione {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.fmt-btn {
cursor: pointer;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 13px;
background: var(--color-surface);
}
.fmt-btn--italic {
font-style: italic;
}
.paragrafo-textarea {
width: 100%;
box-sizing: border-box;
border: none;
background: transparent;
font-size: 16px;
font-family: inherit;
resize: vertical;
color: var(--color-text);
}
.aggiungi-paragrafo {
cursor: pointer;
text-align: center;
padding: 12px;
border-radius: 10px;
border: 1px dashed var(--color-border-strong);
font-weight: 600;
color: var(--color-text-muted);
}
.riepilogo {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 20px;
}
.riepilogo-nome {
font-weight: 700;
font-size: 19px;
margin-bottom: 6px;
}
.riepilogo-meta {
font-size: 14px;
color: var(--color-text-muted);
margin-bottom: 14px;
}
.riepilogo-linea {
font-size: 14px;
margin-top: 6px;
}
.wizard-nav {
display: flex;
justify-content: space-between;
margin-top: 28px;
}
@@ -0,0 +1,157 @@
<div class="page wizard-page">
<h1 class="titolo">{{ wizardTitle() }}</h1>
<div class="steps">
@for (label of stepLabels; track $index) {
<div
class="step-tab"
[class.step-tab--attivo]="step() === $index"
(click)="setStep($index)"
>
{{ $index + 1 }}. {{ label }}
</div>
}
</div>
@if (step() === 0) {
<div class="step-content">
<label class="field">
Nome attività
<input
class="text-input"
type="text"
[value]="nome()"
(input)="nome.set($any($event.target).value)"
placeholder="Es. Caccia al tesoro nel bosco"
/>
</label>
<label class="field">
Stato iniziale
<div class="stato-choices">
@for (s of stati; track s.id) {
<div
class="stato-choice"
[class.stato-choice--attivo]="stato().id === s.id"
[style.background]="stato().id === s.id ? statoStile(s.id).bg : 'transparent'"
[style.color]="stato().id === s.id ? statoStile(s.id).color : 'var(--color-text-muted)'"
[style.border-color]="stato().id === s.id ? statoStile(s.id).border : 'var(--color-border)'"
(click)="selectStato(s)"
>
{{ s.nome }}
</div>
}
</div>
</label>
</div>
}
@if (step() === 1) {
<div class="step-content step-content--gap">
<app-chip-field
label="Branca"
[chips]="brancaList()"
[(query)]="brancaQuery"
[suggestions]="brancaSuggestions()"
(select)="addBranca($event)"
(remove)="removeBranca($event)"
/>
<app-chip-field
label="Categoria"
[chips]="categoriaList()"
[(query)]="categoriaQuery"
[suggestions]="categoriaSuggestions()"
(select)="addCategoria($event)"
(remove)="removeCategoria($event)"
/>
<app-chip-field
label="Periodo dell'anno"
[chips]="periodoAnnoList()"
[(query)]="periodoQuery"
[suggestions]="periodoSuggestions()"
(select)="addPeriodo($event)"
(remove)="removePeriodo($event)"
/>
</div>
}
@if (step() === 2) {
<div class="step-content">
<app-chip-field
label="Materiale"
[chips]="materialeList()"
[(query)]="materialeQuery"
[suggestions]="materialeSuggestions()"
(select)="addMateriale($event)"
(remove)="removeMateriale($event)"
/>
@for (materiale of materialeList(); track materiale.id) {
<label class="field field--sm">
Note/proprietà per "{{ materiale.nome }}"
<input
class="text-input text-input--sm"
type="text"
[value]="materiale.proprieta || ''"
(input)="setMaterialeProprieta(materiale.id, $any($event.target).value)"
placeholder="es. quantità, note d'uso"
/>
</label>
}
</div>
}
@if (step() === 3) {
<div class="step-content">
@for (paragrafo of paragrafoList(); track $index; let i = $index) {
<div class="paragrafo-card">
<div class="paragrafo-header">
<span class="paragrafo-tipo">{{ paragrafo.tipo.id === 'TITOLO' ? 'Titolo' : 'Paragrafo ' + i }}</span>
@if (paragrafo.tipo.id !== 'TITOLO') {
<div class="paragrafo-azioni">
<span class="azione" (click)="moveParagrafo(i, -1)"></span>
<span class="azione" (click)="moveParagrafo(i, 1)"></span>
<span class="azione azione--danger" (click)="removeParagrafo(i)"></span>
</div>
}
</div>
@if (paragrafo.tipo.id !== 'TITOLO') {
<div class="formattazione">
<span class="fmt-btn" (click)="wrapSelection(ta, i, '**')">B</span>
<span class="fmt-btn fmt-btn--italic" (click)="wrapSelection(ta, i, '*')">I</span>
</div>
}
<textarea
#ta
class="paragrafo-textarea"
[value]="paragrafo.corpo"
(input)="updateParagrafoCorpo(i, $any($event.target).value)"
[rows]="paragrafo.tipo.id === 'TITOLO' ? 1 : 3"
[placeholder]="paragrafo.tipo.id === 'TITOLO' ? 'Titolo dell\'attività' : 'Scrivi il contenuto di questo paragrafo...'"
></textarea>
</div>
}
<div class="aggiungi-paragrafo" (click)="addParagrafo()">+ Aggiungi paragrafo</div>
</div>
}
@if (step() === 4) {
<div class="riepilogo">
<div class="riepilogo-nome">{{ nome() }}</div>
<div class="riepilogo-meta">Di {{ autore() }} · Stato: {{ stato().nome }}</div>
<div class="riepilogo-linea">{{ summaryLine() }}</div>
<div class="riepilogo-linea">
{{ nonTitoloCount() }} paragraf{{ nonTitoloCount() === 1 ? 'o' : 'i' }} oltre al titolo
</div>
</div>
}
<div class="wizard-nav">
<div class="btn-secondary" [style.visibility]="step() === 0 ? 'hidden' : 'visible'" (click)="prevStep()">
Indietro
</div>
@if (isLastStep()) {
<div class="btn-primary" (click)="salva()">Salva attività</div>
} @else {
<div class="btn-primary" (click)="nextStep()">Avanti</div>
}
</div>
</div>
@@ -0,0 +1,259 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import {
Attivita,
Branca,
Categoria,
Materiale,
Paragrafo,
PeriodoAnno,
STATI,
STATO_BOZZA,
Stato,
TIPO_PARAGRAFO,
TIPO_TITOLO,
} from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita';
import { AutocompleteService } from '../../core/services/autocomplete';
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'];
@Component({
selector: 'app-activity-form',
imports: [ChipField],
templateUrl: './activity-form.html',
styleUrl: './activity-form.css',
})
export class ActivityForm {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly attivitaService = inject(AttivitaService);
private readonly autocompleteService = inject(AutocompleteService);
private readonly keycloak = inject(Keycloak);
readonly editingId = signal<number | null>(null);
readonly step = signal(0);
readonly stepLabels = STEP_LABELS;
readonly stati = STATI;
readonly nome = signal('');
readonly autore = signal(this.nomeUtenteAutenticato());
readonly stato = signal<Stato>(STATO_BOZZA);
readonly brancaList = signal<Branca[]>([]);
readonly categoriaList = signal<Categoria[]>([]);
readonly periodoAnnoList = signal<PeriodoAnno[]>([]);
readonly materialeList = signal<Materiale[]>([]);
readonly paragrafoList = signal<Paragrafo[]>([{ corpo: '', tipo: TIPO_TITOLO, ordine: 0 }]);
readonly brancaQuery = signal('');
readonly categoriaQuery = signal('');
readonly periodoQuery = signal('');
readonly materialeQuery = signal('');
readonly brancaSuggestions = signal<ChipOption[]>([]);
readonly categoriaSuggestions = signal<ChipOption[]>([]);
readonly periodoSuggestions = signal<ChipOption[]>([]);
readonly materialeSuggestions = signal<ChipOption[]>([]);
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);
readonly summaryLine = computed(() => {
const nomi = [...this.brancaList().map((b) => b.nome), ...this.categoriaList().map((c) => c.nome)];
return nomi.length > 0 ? nomi.join(' · ') : 'Nessuna classificazione selezionata';
});
constructor() {
const idParam = this.route.snapshot.paramMap.get('idAttivita');
if (idParam) {
const id = Number(idParam);
this.editingId.set(id);
this.attivitaService.getOne(id).subscribe((attivita) => this.popolaForm(attivita));
}
effect(() => {
const query = this.brancaQuery();
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([]),
});
});
effect(() => {
const query = this.categoriaQuery();
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([]),
});
});
effect(() => {
const query = this.periodoQuery();
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([]),
});
});
effect(() => {
const query = this.materialeQuery();
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([]),
});
});
}
private toChipOptions(
risultati: { id: number | null; nome: string | null }[],
esclusi: Set<number>
): ChipOption[] {
return risultati
.filter((r): r is { id: number; nome: string } => r.id !== null && r.nome !== null)
.filter((r) => !esclusi.has(r.id));
}
private nomeUtenteAutenticato(): string {
const token = this.keycloak.tokenParsed;
return (token?.['name'] as string) ?? (token?.['preferred_username'] as string) ?? (token?.['email'] as string) ?? '';
}
private popolaForm(attivita: Attivita): void {
this.nome.set(attivita.nome);
this.autore.set(attivita.autore);
this.stato.set(attivita.stato);
this.brancaList.set([...attivita.brancaList]);
this.categoriaList.set([...attivita.categoriaList]);
this.periodoAnnoList.set([...attivita.periodoAnnoList]);
this.materialeList.set(attivita.materialeList.map((m) => ({ ...m })));
this.paragrafoList.set(attivita.paragrafoList.map((p) => ({ ...p })));
}
selectStato(stato: Stato): void {
this.stato.set(stato);
}
statoStile(idStato: string) {
return statoStyle(idStato);
}
addBranca(chip: ChipOption): void {
this.brancaList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
this.brancaQuery.set('');
}
removeBranca(chip: ChipOption): void {
this.brancaList.update((lista) => lista.filter((b) => b.id !== chip.id));
}
addCategoria(chip: ChipOption): void {
this.categoriaList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
this.categoriaQuery.set('');
}
removeCategoria(chip: ChipOption): void {
this.categoriaList.update((lista) => lista.filter((c) => c.id !== chip.id));
}
addPeriodo(chip: ChipOption): void {
this.periodoAnnoList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
this.periodoQuery.set('');
}
removePeriodo(chip: ChipOption): void {
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== chip.id));
}
addMateriale(chip: ChipOption): void {
this.materialeList.update((lista) => [...lista, { id: chip.id, nome: chip.nome, proprieta: '' }]);
this.materialeQuery.set('');
}
removeMateriale(chip: ChipOption): void {
this.materialeList.update((lista) => lista.filter((m) => m.id !== chip.id));
}
setMaterialeProprieta(id: number, valore: string): void {
this.materialeList.update((lista) => lista.map((m) => (m.id === id ? { ...m, proprieta: valore } : m)));
}
addParagrafo(): void {
this.paragrafoList.update((lista) => [
...lista,
{ corpo: '', tipo: TIPO_PARAGRAFO, ordine: lista.length },
]);
}
removeParagrafo(idx: number): void {
this.paragrafoList.update((lista) =>
lista.filter((_, i) => i !== idx).map((p, i) => ({ ...p, ordine: i }))
);
}
moveParagrafo(idx: number, direzione: -1 | 1): void {
this.paragrafoList.update((lista) => {
const j = idx + direzione;
if (j < 1 || j >= lista.length) {
return lista;
}
const copia = [...lista];
[copia[idx], copia[j]] = [copia[j], copia[idx]];
return copia.map((p, i) => ({ ...p, ordine: i }));
});
}
updateParagrafoCorpo(idx: number, valore: string): void {
this.paragrafoList.update((lista) => lista.map((p, i) => (i === idx ? { ...p, corpo: valore } : p)));
}
wrapSelection(textarea: HTMLTextAreaElement, idx: number, marker: string): void {
const valore = this.paragrafoList()[idx].corpo || '';
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selezione = valore.slice(start, end) || 'testo';
const nuovoValore = valore.slice(0, start) + marker + selezione + marker + valore.slice(end);
this.updateParagrafoCorpo(idx, nuovoValore);
setTimeout(() => {
textarea.focus();
textarea.selectionStart = start + marker.length;
textarea.selectionEnd = start + marker.length + selezione.length;
});
}
nextStep(): void {
this.step.update((s) => Math.min(4, s + 1));
}
prevStep(): void {
this.step.update((s) => Math.max(0, s - 1));
}
setStep(n: number): void {
this.step.set(n);
}
salva(): void {
const attivita: Attivita = {
id: this.editingId() ?? undefined,
nome: this.nome(),
autore: this.autore(),
stato: this.stato(),
brancaList: this.brancaList(),
categoriaList: this.categoriaList(),
periodoAnnoList: this.periodoAnnoList(),
materialeList: this.materialeList(),
paragrafoList: this.paragrafoList(),
};
this.attivitaService.save(attivita).subscribe(() => this.router.navigate(['/mie-attivita']));
}
}
@@ -0,0 +1,125 @@
.page {
max-width: 760px;
padding-top: 36px;
}
.branca-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.branca-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
}
.branca-nomi {
font-size: 14px;
color: var(--color-text-muted);
}
.titolo {
font-size: 32px;
margin: 6px 0 8px;
font-weight: 700;
}
.meta {
font-size: 15px;
color: var(--color-text-muted);
margin-bottom: 16px;
}
.stato {
font-weight: 700;
}
.chips-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.chip {
padding: 6px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
}
.chip--categoria {
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.chip--periodo {
background: var(--color-periodo-bg);
border: 1px solid var(--color-periodo-border);
color: var(--color-periodo-text);
}
.materiale-box {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 16px 20px;
margin-bottom: 28px;
}
.materiale-title {
font-weight: 700;
font-size: 15px;
margin-bottom: 10px;
}
.materiale-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.materiale-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
}
.materiale-icon {
color: var(--color-primary-text-strong);
}
.materiale-nome {
font-weight: 600;
}
.materiale-proprieta {
color: var(--color-text-muted);
}
.paragrafi {
display: flex;
flex-direction: column;
gap: 18px;
}
.paragrafo-titolo {
font-size: 22px;
font-weight: 700;
margin: 8px 0 0;
border-bottom: 2px solid var(--color-primary);
padding-bottom: 8px;
}
.paragrafo-corpo {
font-size: 17px;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
}
@@ -0,0 +1,58 @@
@if (attivita(); as a) {
<div class="page">
<div class="branca-row">
@for (branca of a.brancaList; track branca.id) {
<span class="branca-dot" [style.background]="branca.colore" [title]="branca.nome"></span>
}
<span class="branca-nomi">{{ brancaNomi() }}</span>
</div>
<h1 class="titolo">{{ a.nome }}</h1>
<div class="meta">
Di {{ a.autore }} · Aggiornata il {{ dataModificaFmt() }} ·
<span class="stato" [style.color]="statoTextColor()">{{ a.stato.nome }}</span>
</div>
<div class="chips-row">
@for (categoria of a.categoriaList; track categoria.id) {
<span class="chip chip--categoria">{{ categoria.nome }}</span>
}
@for (periodo of a.periodoAnnoList; track periodo.id) {
<span class="chip chip--periodo">{{ periodo.nome }}</span>
}
</div>
@if (a.materialeList.length > 0) {
<div class="materiale-box">
<div class="materiale-title">Materiale occorrente</div>
<div class="materiale-list">
@for (materiale of a.materialeList; track materiale.id) {
<div class="materiale-item">
<span class="materiale-icon">🧰</span>
<span class="materiale-nome">{{ materiale.nome }}</span>
@if (materiale.proprieta) {
<span class="materiale-proprieta">— {{ materiale.proprieta }}</span>
}
</div>
}
</div>
</div>
}
<div class="paragrafi">
@for (paragrafo of paragrafiOrdinati(); track paragrafo.id) {
@if (isTitolo(paragrafo)) {
<h2 class="paragrafo-titolo">{{ paragrafo.corpo }}</h2>
} @else {
<p class="paragrafo-corpo">
@for (token of tokens(paragrafo); track $index) {
<span [style.font-weight]="token.weight" [style.font-style]="token.style">{{ token.text }}</span>
}
</p>
}
}
</div>
</div>
}
@if (!attivita() && !caricamento()) {
<div class="page empty-state">Attività non trovata.</div>
}
@@ -0,0 +1,46 @@
import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Attivita, Paragrafo } from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita';
import { formatData, parseMarkdown, statoStyle } from '../../core/utils/stato-style';
@Component({
selector: 'app-detail',
imports: [],
templateUrl: './detail.html',
styleUrl: './detail.css',
})
export class Detail {
private readonly route = inject(ActivatedRoute);
private readonly attivitaService = inject(AttivitaService);
readonly attivita = signal<Attivita | null>(null);
readonly caricamento = signal(true);
readonly dataModificaFmt = computed(() => formatData(this.attivita()?.dataModifica));
readonly statoTextColor = computed(() => statoStyle(this.attivita()?.stato.id ?? 'BO').color);
readonly brancaNomi = computed(() => (this.attivita()?.brancaList ?? []).map((b) => b.nome).join(', '));
readonly paragrafiOrdinati = computed(() =>
[...(this.attivita()?.paragrafoList ?? [])].sort((a, b) => a.ordine - b.ordine)
);
constructor() {
const idAttivita = Number(this.route.snapshot.paramMap.get('idAttivita'));
this.attivitaService.getOne(idAttivita).subscribe({
next: (attivita) => {
this.attivita.set(attivita);
this.caricamento.set(false);
},
error: () => this.caricamento.set(false),
});
}
isTitolo(paragrafo: Paragrafo): boolean {
return paragrafo.tipo.id === 'TITOLO';
}
tokens(paragrafo: Paragrafo) {
return parseMarkdown(paragrafo.corpo);
}
}
@@ -0,0 +1,24 @@
<div class="page">
<div class="page-header">
<h1 class="page-title">Attività pubblicate</h1>
<a class="search-bar" routerLink="/ricerca">
<span>🔍</span><span>Cerca un'attività...</span>
</a>
</div>
@if (!caricamento() && attivitaList().length > 0) {
<div class="grid">
@for (attivita of attivitaList(); track attivita.id) {
<app-activity-card [attivita]="attivita" />
}
</div>
}
@if (!caricamento() && attivitaList().length === 0) {
<div class="empty-state">
<div class="empty-icon">🏕️</div>
<div class="empty-title">Nessuna attività pubblicata</div>
<div class="empty-subtitle">Le attività pubblicate dai capi compariranno qui.</div>
</div>
}
</div>
@@ -0,0 +1,29 @@
import { Component, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Attivita } from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita';
import { ActivityCard } from '../../shared/activity-card/activity-card';
@Component({
selector: 'app-home',
imports: [RouterLink, ActivityCard],
templateUrl: './home.html',
styleUrl: './home.css',
})
export class Home {
private readonly attivitaService = inject(AttivitaService);
readonly attivitaList = signal<Attivita[]>([]);
readonly caricamento = signal(true);
constructor() {
this.attivitaService.getListHome().subscribe({
next: (lista) => {
this.attivitaList.set(lista);
this.caricamento.set(false);
},
error: () => this.caricamento.set(false),
});
}
}
@@ -0,0 +1,57 @@
.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 {
cursor: pointer;
font-weight: 700;
font-size: 17px;
color: var(--color-text);
}
.riga-data {
font-size: 13px;
color: var(--color-text-muted);
margin-top: 2px;
}
.stato-options {
display: flex;
gap: 6px;
}
.stato-option {
cursor: pointer;
padding: 7px 13px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
border: 1px solid var(--color-border);
}
.riga-modifica {
cursor: pointer;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-border);
font-weight: 600;
font-size: 14px;
}
@@ -0,0 +1,39 @@
<div class="page">
<div class="page-header">
<h1 class="page-title page-title--sm">Le mie attività</h1>
<a class="btn-primary" routerLink="/nuova-attivita">+ Nuova attività</a>
</div>
@if (attivitaList().length > 0) {
<div class="lista">
@for (attivita of attivitaList(); track attivita.id) {
<div class="riga">
<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>
</div>
<div class="stato-options">
@for (stato of stati; track stato.id) {
<div
class="stato-option"
[class.stato-option--attivo]="isStatoAttivo(attivita, stato)"
[style.background]="isStatoAttivo(attivita, stato) ? stileStato(stato.id).bg : 'transparent'"
[style.color]="isStatoAttivo(attivita, stato) ? stileStato(stato.id).color : 'var(--color-text-muted)'"
[style.border-color]="isStatoAttivo(attivita, stato) ? stileStato(stato.id).border : 'var(--color-border)'"
(click)="cambiaStato(attivita, stato)"
>
{{ stato.nome }}
</div>
}
</div>
<div class="riga-modifica" (click)="modifica(attivita)">Modifica</div>
</div>
}
</div>
} @else if (!caricamento()) {
<div class="empty-state">
<div class="empty-icon">📋</div>
<div class="empty-title">Non hai ancora creato attività</div>
</div>
}
</div>
@@ -0,0 +1,58 @@
import { Component, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { Attivita, STATI, Stato } from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita';
import { formatData, statoStyle } from '../../core/utils/stato-style';
@Component({
selector: 'app-my-activity',
imports: [RouterLink],
templateUrl: './my-activity.html',
styleUrl: './my-activity.css',
})
export class MyActivity {
private readonly attivitaService = inject(AttivitaService);
private readonly router = inject(Router);
readonly attivitaList = signal<Attivita[]>([]);
readonly caricamento = signal(true);
readonly stati = STATI;
constructor() {
this.caricaLista();
}
private caricaLista(): void {
this.attivitaService.getListMy().subscribe({
next: (lista) => {
this.attivitaList.set(lista);
this.caricamento.set(false);
},
error: () => this.caricamento.set(false),
});
}
dataModificaFmt(attivita: Attivita): string {
return formatData(attivita.dataModifica);
}
stileStato(idStato: string) {
return statoStyle(idStato);
}
isStatoAttivo(attivita: Attivita, stato: Stato): boolean {
return attivita.stato.id === stato.id;
}
cambiaStato(attivita: Attivita, stato: Stato): void {
if (!attivita.id || attivita.stato.id === stato.id) {
return;
}
this.attivitaService.changeStato(attivita.id, stato.id).subscribe(() => this.caricaLista());
}
modifica(attivita: Attivita): void {
this.router.navigate(['/modifica-attivita', attivita.id]);
}
}
@@ -0,0 +1,30 @@
.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;
}
@@ -0,0 +1,8 @@
<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>
@@ -0,0 +1,10 @@
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 {}
@@ -0,0 +1,85 @@
.page-title {
font-size: 28px;
margin: 0 0 18px;
font-weight: 700;
}
.search-input-wrap {
position: relative;
max-width: 640px;
}
.search-input {
width: 100%;
box-sizing: border-box;
padding: 14px 16px;
font-size: 16px;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
}
.suggestions {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
box-shadow: var(--shadow-menu);
z-index: 5;
max-height: 360px;
overflow: auto;
}
.suggestion-group-label {
padding: 10px 16px 4px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.suggestion-item {
padding: 10px 16px;
cursor: pointer;
font-size: 15px;
}
.suggestion-item:hover {
background: var(--color-primary-soft);
}
.active-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0 28px;
}
.filter-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
background: var(--color-primary-soft);
border: 1px solid var(--color-primary-soft-border);
font-size: 14px;
font-weight: 600;
color: var(--color-primary-text);
}
.filter-remove {
cursor: pointer;
font-weight: 700;
}
.results-label {
font-weight: 700;
color: var(--color-text-muted);
margin-bottom: 12px;
}
@@ -0,0 +1,47 @@
<div class="page">
<h1 class="page-title">Cerca attività</h1>
<div class="search-input-wrap">
<input
class="search-input"
type="text"
[value]="searchQuery()"
(input)="onQueryChange($event)"
(keydown)="onQueryKeyDown($event)"
placeholder="Cerca per nome, branca, categoria, materiale, periodo..."
/>
@if (showSuggestions()) {
<div class="suggestions">
@for (group of suggestionGroups(); track group.label) {
<div class="suggestion-group-label">{{ group.label }}</div>
@for (suggestion of group.objectsList; track suggestion.id) {
<div class="suggestion-item" (click)="selectSuggestion(suggestion)">
{{ suggestion.nome }}
</div>
}
}
</div>
}
</div>
<div class="active-filters">
@for (filtro of activeFilters(); track $index) {
<div class="filter-chip">
<span>{{ filtroLabel(filtro.gruppo) }}: {{ filtro.nome }}</span>
<span class="filter-remove" (click)="removeFiltro($index)">×</span>
</div>
}
</div>
<div class="results-label">{{ resultsLabel() }}</div>
@if (results().length > 0) {
<div class="grid">
@for (attivita of results(); track attivita.id) {
<app-activity-card [attivita]="attivita" />
}
</div>
} @else {
<div class="empty-state">Nessun risultato con questi filtri.</div>
}
</div>
@@ -0,0 +1,92 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { Attivita } from '../../core/models/attivita.model';
import { AutocompleteGroup, GruppoFiltro, SearchObjectDto } from '../../core/models/search.model';
import { AttivitaService } from '../../core/services/attivita';
import { AutocompleteService } from '../../core/services/autocomplete';
import { NavigationService } from '../../core/services/navigation';
import { ActivityCard } from '../../shared/activity-card/activity-card';
@Component({
selector: 'app-search',
imports: [ActivityCard],
templateUrl: './search.html',
styleUrl: './search.css',
})
export class Search {
private readonly attivitaService = inject(AttivitaService);
private readonly autocompleteService = inject(AutocompleteService);
readonly navigationService = inject(NavigationService);
readonly searchQuery = this.navigationService.searchQuery;
readonly activeFilters = this.navigationService.filtri;
readonly suggestionGroups = signal<AutocompleteGroup[]>([]);
readonly showSuggestions = computed(() => this.suggestionGroups().length > 0);
readonly results = signal<Attivita[]>([]);
readonly resultsLabel = computed(() => {
const n = this.results().length;
return `${n} risultat${n === 1 ? 'o' : 'i'}`;
});
constructor() {
effect(() => {
const filtri: SearchObjectDto[] = this.activeFilters();
this.attivitaService.search(filtri).subscribe({
next: (lista) => this.results.set(lista),
error: () => this.results.set([]),
});
});
}
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([]),
});
}
onQueryKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && this.searchQuery().trim()) {
this.navigationService.addFiltro({ id: null, nome: this.searchQuery().trim(), gruppo: 'testo' });
this.searchQuery.set('');
this.suggestionGroups.set([]);
}
}
selectSuggestion(suggestion: SearchObjectDto): void {
const nome = suggestion.nome ?? this.searchQuery().trim();
if (!nome) {
return;
}
this.navigationService.addFiltro({
id: suggestion.id,
nome,
gruppo: suggestion.gruppo as GruppoFiltro,
});
this.searchQuery.set('');
this.suggestionGroups.set([]);
}
removeFiltro(index: number): void {
this.navigationService.removeFiltro(index);
}
filtroLabel(gruppo: string): string {
const etichette: Record<string, string> = {
testo: 'Testo',
branca: 'Branca',
categoria: 'Categoria',
materiale: 'Materiale',
periodoAnno: 'Periodo',
};
return etichette[gruppo] ?? gruppo;
}
}