From 0fdf395733d5007f33599a8368eae8f594174ac1 Mon Sep 17 00:00:00 2001 From: Lorenzo Sanesi Date: Sat, 1 Aug 2026 03:12:23 +0200 Subject: [PATCH] Sistemato magazzino --- keycloak/realm-export.json | 6 + scouthub-magazzino-be/Dockerfile | 2 +- scouthub-magazzino-be/package.json | 4 + .../20260724074152_init/migration.sql | 107 ++- scouthub-magazzino-be/prisma/schema.prisma | 179 +++- scouthub-magazzino-be/prisma/seed.ts | 204 +++++ scouthub-magazzino-be/src/app.ts | 15 +- .../controllers/autocomplete.controller.ts | 16 + .../src/controllers/categorie.controller.ts | 99 +++ .../controllers/gruppiMagazzino.controller.ts | 88 ++ .../src/controllers/liste.controller.ts | 200 ++++- .../controllers/listeModello.controller.ts | 111 --- .../src/controllers/magazzino.controller.ts | 10 + .../src/controllers/materiali.controller.ts | 51 +- .../src/controllers/notifiche.controller.ts | 45 + .../src/controllers/tipiEvento.controller.ts | 56 +- .../src/repositories/categorie.repository.ts | 46 ++ .../gruppiMagazzino.repository.ts | 60 ++ .../src/repositories/liste.repository.ts | 202 ++++- .../repositories/listeModello.repository.ts | 82 -- .../src/repositories/magazzino.repository.ts | 2 + .../src/repositories/materiali.repository.ts | 25 +- .../src/repositories/notifiche.repository.ts | 36 + .../src/repositories/tipiEvento.repository.ts | 32 +- .../src/routes/autocomplete.routes.ts | 6 + .../src/routes/categorie.routes.ts | 25 + .../src/routes/gruppiMagazzino.routes.ts | 16 + .../src/routes/liste.routes.ts | 31 +- .../src/routes/listeModello.routes.ts | 16 - .../src/routes/materiali.routes.ts | 13 +- .../src/routes/notifiche.routes.ts | 11 + .../src/routes/tipiEvento.routes.ts | 16 +- .../src/services/autocomplete.service.ts | 36 + .../src/services/categorie.service.ts | 76 ++ .../src/services/gruppiMagazzino.service.ts | 115 +++ .../src/services/liste.service.ts | 408 ++++++++- .../src/services/listeModello.service.ts | 81 -- .../src/services/magazzino.service.ts | 23 +- .../src/services/materiali.service.ts | 55 +- .../src/services/notifiche.service.ts | 55 ++ .../src/services/tipiEvento.service.ts | 55 +- .../integration/categorie.endpoint.test.ts | 237 ++++++ .../tests/integration/liste.endpoint.test.ts | 777 +++++++++++++++--- .../integration/listeModello.endpoint.test.ts | 252 ------ .../integration/tipiEvento.endpoint.test.ts | 145 +++- scouthub-magazzino-fe/angular.json | 2 +- scouthub-magazzino-fe/src/app/app.css | 3 + scouthub-magazzino-fe/src/app/app.html | 6 +- scouthub-magazzino-fe/src/app/app.routes.ts | 8 +- scouthub-magazzino-fe/src/app/app.spec.ts | 14 + scouthub-magazzino-fe/src/app/app.ts | 4 +- .../app/catalogo/autocomplete-api.service.ts | 37 + .../catalogo-liste-modello.css | 83 +- .../catalogo-liste-modello.html | 103 ++- .../catalogo-liste-modello.spec.ts | 216 ++++- .../catalogo-liste-modello.ts | 84 +- .../catalogo-materiali/catalogo-materiali.css | 6 - .../catalogo-materiali.html | 14 +- .../catalogo-materiali.spec.ts | 50 +- .../catalogo-materiali/catalogo-materiali.ts | 11 - .../src/app/catalogo/catalogo.routes.ts | 27 +- .../app/catalogo/cerca-lista/cerca-lista.css | 155 ++++ .../app/catalogo/cerca-lista/cerca-lista.html | 115 +++ .../app/catalogo/cerca-lista/cerca-lista.ts | 231 ++++++ .../lista-modello-dettaglio.css | 69 ++ .../lista-modello-dettaglio.html | 80 ++ .../lista-modello-dettaglio.spec.ts | 212 +++++ .../lista-modello-dettaglio.ts | 116 +++ .../src/app/catalogo/liste-api.service.ts | 29 - .../app/catalogo/liste-modello-api.service.ts | 28 - .../src/app/catalogo/materiali-api.service.ts | 25 +- .../proponi-materiale/proponi-materiale.css | 21 - .../proponi-materiale/proponi-materiale.html | 58 -- .../proponi-materiale.spec.ts | 96 --- .../proponi-materiale/proponi-materiale.ts | 72 -- .../app/catalogo/tipi-evento-api.service.ts | 42 +- .../catalogo/voce-check-storage.service.ts | 48 ++ .../src/app/core/models/notifica.model.ts | 14 + .../src/app/core/services/notifiche.ts | 30 + .../app/eventi/crea-evento/crea-evento.css | 17 - .../app/eventi/crea-evento/crea-evento.html | 57 -- .../eventi/crea-evento/crea-evento.spec.ts | 122 --- .../src/app/eventi/crea-evento/crea-evento.ts | 86 -- .../src/app/eventi/eventi-api.service.ts | 53 -- .../src/app/eventi/eventi.routes.ts | 16 - .../evento-dettaglio/evento-dettaglio.css | 52 -- .../evento-dettaglio/evento-dettaglio.html | 74 -- .../evento-dettaglio/evento-dettaglio.spec.ts | 148 ---- .../evento-dettaglio/evento-dettaglio.ts | 102 --- .../app/liste/lista-editor/lista-editor.css | 59 -- .../app/liste/lista-editor/lista-editor.html | 92 --- .../liste/lista-editor/lista-editor.spec.ts | 181 ---- .../app/liste/lista-editor/lista-editor.ts | 133 --- .../src/app/liste/lista.model.ts | 34 + .../src/app/liste/liste-api.service.ts | 67 +- .../src/app/liste/liste-list/liste-list.css | 122 ++- .../src/app/liste/liste-list/liste-list.html | 96 ++- .../app/liste/liste-list/liste-list.spec.ts | 185 +++-- .../src/app/liste/liste-list/liste-list.ts | 140 ++-- .../src/app/liste/liste.routes.ts | 16 +- .../src/app/liste/nuova-lista/nuova-lista.css | 328 ++++++++ .../app/liste/nuova-lista/nuova-lista.html | 306 +++++++ .../app/liste/nuova-lista/nuova-lista.spec.ts | 422 ++++++++++ .../src/app/liste/nuova-lista/nuova-lista.ts | 578 +++++++++++++ .../src/app/magazzino/magazzino.css | 116 ++- .../src/app/magazzino/magazzino.html | 127 ++- .../src/app/magazzino/magazzino.spec.ts | 8 +- .../src/app/magazzino/magazzino.ts | 68 +- .../src/app/moderazione/moderazione.css | 17 - .../src/app/moderazione/moderazione.html | 44 - .../src/app/moderazione/moderazione.spec.ts | 120 --- .../src/app/moderazione/moderazione.ts | 63 -- .../src/app/shared/header/header.css | 218 +++++ .../src/app/shared/header/header.html | 75 ++ .../src/app/shared/header/header.ts | 180 ++++ .../app/tassonomie/categorie-api.service.ts | 53 ++ .../liste-moderazione-api.service.ts | 23 + .../materiali-moderazione-api.service.ts | 0 .../require-moderatore.guard.spec.ts | 0 .../require-moderatore.guard.ts | 0 .../src/app/tassonomie/tassonomie.css | 227 +++++ .../src/app/tassonomie/tassonomie.html | 319 +++++++ .../tassonomie.routes.ts} | 4 +- .../src/app/tassonomie/tassonomie.spec.ts | 400 +++++++++ .../src/app/tassonomie/tassonomie.ts | 443 ++++++++++ scouthub-magazzino-fe/src/index.html | 7 - scouthub-magazzino-fe/src/material-theme.scss | 38 - scouthub-magazzino-fe/src/styles.css | 111 +-- 128 files changed, 9307 insertions(+), 3076 deletions(-) create mode 100644 scouthub-magazzino-be/prisma/seed.ts create mode 100644 scouthub-magazzino-be/src/controllers/autocomplete.controller.ts create mode 100644 scouthub-magazzino-be/src/controllers/categorie.controller.ts create mode 100644 scouthub-magazzino-be/src/controllers/gruppiMagazzino.controller.ts delete mode 100644 scouthub-magazzino-be/src/controllers/listeModello.controller.ts create mode 100644 scouthub-magazzino-be/src/controllers/notifiche.controller.ts create mode 100644 scouthub-magazzino-be/src/repositories/categorie.repository.ts create mode 100644 scouthub-magazzino-be/src/repositories/gruppiMagazzino.repository.ts delete mode 100644 scouthub-magazzino-be/src/repositories/listeModello.repository.ts create mode 100644 scouthub-magazzino-be/src/repositories/notifiche.repository.ts create mode 100644 scouthub-magazzino-be/src/routes/autocomplete.routes.ts create mode 100644 scouthub-magazzino-be/src/routes/categorie.routes.ts create mode 100644 scouthub-magazzino-be/src/routes/gruppiMagazzino.routes.ts delete mode 100644 scouthub-magazzino-be/src/routes/listeModello.routes.ts create mode 100644 scouthub-magazzino-be/src/routes/notifiche.routes.ts create mode 100644 scouthub-magazzino-be/src/services/autocomplete.service.ts create mode 100644 scouthub-magazzino-be/src/services/categorie.service.ts create mode 100644 scouthub-magazzino-be/src/services/gruppiMagazzino.service.ts delete mode 100644 scouthub-magazzino-be/src/services/listeModello.service.ts create mode 100644 scouthub-magazzino-be/src/services/notifiche.service.ts create mode 100644 scouthub-magazzino-be/tests/integration/categorie.endpoint.test.ts delete mode 100644 scouthub-magazzino-be/tests/integration/listeModello.endpoint.test.ts create mode 100644 scouthub-magazzino-fe/src/app/catalogo/autocomplete-api.service.ts create mode 100644 scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.css create mode 100644 scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.html create mode 100644 scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.ts create mode 100644 scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.css create mode 100644 scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.html create mode 100644 scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.spec.ts create mode 100644 scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.ts delete mode 100644 scouthub-magazzino-fe/src/app/catalogo/liste-api.service.ts delete mode 100644 scouthub-magazzino-fe/src/app/catalogo/liste-modello-api.service.ts delete mode 100644 scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.css delete mode 100644 scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.html delete mode 100644 scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.spec.ts delete mode 100644 scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.ts create mode 100644 scouthub-magazzino-fe/src/app/catalogo/voce-check-storage.service.ts create mode 100644 scouthub-magazzino-fe/src/app/core/models/notifica.model.ts create mode 100644 scouthub-magazzino-fe/src/app/core/services/notifiche.ts delete mode 100644 scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.css delete mode 100644 scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.html delete mode 100644 scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.spec.ts delete mode 100644 scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.ts delete mode 100644 scouthub-magazzino-fe/src/app/eventi/eventi-api.service.ts delete mode 100644 scouthub-magazzino-fe/src/app/eventi/eventi.routes.ts delete mode 100644 scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.css delete mode 100644 scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.html delete mode 100644 scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.spec.ts delete mode 100644 scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.ts delete mode 100644 scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.css delete mode 100644 scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.html delete mode 100644 scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.spec.ts delete mode 100644 scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.ts create mode 100644 scouthub-magazzino-fe/src/app/liste/lista.model.ts create mode 100644 scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.css create mode 100644 scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.html create mode 100644 scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.spec.ts create mode 100644 scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.ts delete mode 100644 scouthub-magazzino-fe/src/app/moderazione/moderazione.css delete mode 100644 scouthub-magazzino-fe/src/app/moderazione/moderazione.html delete mode 100644 scouthub-magazzino-fe/src/app/moderazione/moderazione.spec.ts delete mode 100644 scouthub-magazzino-fe/src/app/moderazione/moderazione.ts create mode 100644 scouthub-magazzino-fe/src/app/shared/header/header.css create mode 100644 scouthub-magazzino-fe/src/app/shared/header/header.html create mode 100644 scouthub-magazzino-fe/src/app/shared/header/header.ts create mode 100644 scouthub-magazzino-fe/src/app/tassonomie/categorie-api.service.ts create mode 100644 scouthub-magazzino-fe/src/app/tassonomie/liste-moderazione-api.service.ts rename scouthub-magazzino-fe/src/app/{moderazione => tassonomie}/materiali-moderazione-api.service.ts (100%) rename scouthub-magazzino-fe/src/app/{moderazione => tassonomie}/require-moderatore.guard.spec.ts (100%) rename scouthub-magazzino-fe/src/app/{moderazione => tassonomie}/require-moderatore.guard.ts (100%) create mode 100644 scouthub-magazzino-fe/src/app/tassonomie/tassonomie.css create mode 100644 scouthub-magazzino-fe/src/app/tassonomie/tassonomie.html rename scouthub-magazzino-fe/src/app/{moderazione/moderazione.routes.ts => tassonomie/tassonomie.routes.ts} (68%) create mode 100644 scouthub-magazzino-fe/src/app/tassonomie/tassonomie.spec.ts create mode 100644 scouthub-magazzino-fe/src/app/tassonomie/tassonomie.ts delete mode 100644 scouthub-magazzino-fe/src/material-theme.scss diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 5b55645..18af4c6 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -13,6 +13,12 @@ "displayNameHtml": "Scouthub", "loginTheme": "scouthub", + "accessTokenLifespan": 3600, + "ssoSessionIdleTimeout": 28800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 28800, + "offlineSessionMaxLifespan": 36000, + "roles": { "realm": [ { diff --git a/scouthub-magazzino-be/Dockerfile b/scouthub-magazzino-be/Dockerfile index 471fbad..517c3d9 100644 --- a/scouthub-magazzino-be/Dockerfile +++ b/scouthub-magazzino-be/Dockerfile @@ -13,4 +13,4 @@ RUN npm run build EXPOSE 8083 -CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"] +CMD ["sh", "-c", "npx prisma migrate deploy && npx prisma db seed && node dist/server.js"] diff --git a/scouthub-magazzino-be/package.json b/scouthub-magazzino-be/package.json index 118b4de..3f643fd 100644 --- a/scouthub-magazzino-be/package.json +++ b/scouthub-magazzino-be/package.json @@ -12,6 +12,7 @@ "start": "node dist/server.js", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", + "db:seed": "prisma db seed", "test": "jest --runInBand --passWithNoTests", "test:watch": "jest --watch --runInBand" }, @@ -38,5 +39,8 @@ "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "typescript": "^5.5.4" + }, + "prisma": { + "seed": "ts-node prisma/seed.ts" } } diff --git a/scouthub-magazzino-be/prisma/migrations/20260724074152_init/migration.sql b/scouthub-magazzino-be/prisma/migrations/20260724074152_init/migration.sql index 496ce34..e87b1d3 100644 --- a/scouthub-magazzino-be/prisma/migrations/20260724074152_init/migration.sql +++ b/scouthub-magazzino-be/prisma/migrations/20260724074152_init/migration.sql @@ -4,6 +4,18 @@ CREATE TYPE "stato_materiale" AS ENUM ('proposto', 'approvato', 'rifiutato'); -- CreateEnum CREATE TYPE "stato_magazzino_voce" AS ENUM ('buono', 'da_riparare', 'mancante'); +-- CreateEnum +CREATE TYPE "stato_lista" AS ENUM ('bozza', 'privato', 'gruppo', 'pubblico'); + +-- CreateEnum +CREATE TYPE "stato_moderazione_lista" AS ENUM ('proposto', 'approvato', 'rifiutato'); + +-- CreateEnum +CREATE TYPE "stato_categoria" AS ENUM ('confermata', 'da_approvare'); + +-- CreateEnum +CREATE TYPE "tipo_notifica" AS ENUM ('MATERIALE_PROPOSTO', 'CATEGORIA_PROPOSTA', 'TIPO_EVENTO_PROPOSTO', 'LISTA_PROPOSTA'); + -- CreateTable CREATE TABLE "materiale" ( "id" TEXT NOT NULL, @@ -21,35 +33,35 @@ CREATE TABLE "materiale" ( CREATE TABLE "tipo_evento" ( "id" TEXT NOT NULL, "nome" TEXT NOT NULL, + "stato" "stato_categoria" NOT NULL DEFAULT 'confermata', + "creato_da_org_id" TEXT, + "creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "tipo_evento_pkey" PRIMARY KEY ("id") ); -- CreateTable -CREATE TABLE "lista_modello" ( +CREATE TABLE "categoria" ( "id" TEXT NOT NULL, "nome" TEXT NOT NULL, - "tipo_evento_id" TEXT NOT NULL, - "pubblica" BOOLEAN NOT NULL DEFAULT true, + "stato" "stato_categoria" NOT NULL DEFAULT 'confermata', + "creato_da_org_id" TEXT, + "creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "lista_modello_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "lista_modello_voce" ( - "lista_modello_id" TEXT NOT NULL, - "materiale_id" TEXT NOT NULL, - "quantita" INTEGER NOT NULL, - - CONSTRAINT "lista_modello_voce_pkey" PRIMARY KEY ("lista_modello_id","materiale_id") + CONSTRAINT "categoria_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "lista" ( "id" TEXT NOT NULL, "nome" TEXT NOT NULL, - "org_id" TEXT NOT NULL, + "org_id" TEXT, + "creata_da_user_id" TEXT, + "stato" "stato_lista" NOT NULL DEFAULT 'bozza', "creata_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "stato_moderazione" "stato_moderazione_lista", + "tipo_evento_id" TEXT, + "parent_id" TEXT, CONSTRAINT "lista_pkey" PRIMARY KEY ("id") ); @@ -63,6 +75,14 @@ CREATE TABLE "lista_voce" ( CONSTRAINT "lista_voce_pkey" PRIMARY KEY ("lista_id","materiale_id") ); +-- CreateTable +CREATE TABLE "lista_sotto_lista" ( + "lista_id" TEXT NOT NULL, + "sotto_lista_id" TEXT NOT NULL, + + CONSTRAINT "lista_sotto_lista_pkey" PRIMARY KEY ("lista_id","sotto_lista_id") +); + -- CreateTable CREATE TABLE "magazzino_voce" ( "id" TEXT NOT NULL, @@ -72,10 +92,22 @@ CREATE TABLE "magazzino_voce" ( "stato" "stato_magazzino_voce" NOT NULL, "posizione" TEXT, "note" TEXT, + "gruppo_id" TEXT, CONSTRAINT "magazzino_voce_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "gruppo_magazzino" ( + "id" TEXT NOT NULL, + "org_id" TEXT NOT NULL, + "nome" TEXT NOT NULL, + "parent_id" TEXT, + "creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "gruppo_magazzino_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "evento" ( "id" TEXT NOT NULL, @@ -97,14 +129,41 @@ CREATE TABLE "evento_check" ( CONSTRAINT "evento_check_pkey" PRIMARY KEY ("evento_id","materiale_id") ); --- AddForeignKey -ALTER TABLE "lista_modello" ADD CONSTRAINT "lista_modello_tipo_evento_id_fkey" FOREIGN KEY ("tipo_evento_id") REFERENCES "tipo_evento"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- CreateTable +CREATE TABLE "notifica" ( + "id" SERIAL NOT NULL, + "tipo" "tipo_notifica" NOT NULL, + "messaggio" TEXT NOT NULL, + "link" TEXT, + "letta" BOOLEAN NOT NULL DEFAULT false, + "data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notifica_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "lista_stato_stato_moderazione_idx" ON "lista"("stato", "stato_moderazione"); + +-- CreateIndex +CREATE INDEX "lista_tipo_evento_id_idx" ON "lista"("tipo_evento_id"); + +-- CreateIndex +CREATE INDEX "lista_parent_id_idx" ON "lista"("parent_id"); + +-- CreateIndex +CREATE INDEX "gruppo_magazzino_org_id_idx" ON "gruppo_magazzino"("org_id"); + +-- CreateIndex +CREATE INDEX "gruppo_magazzino_parent_id_idx" ON "gruppo_magazzino"("parent_id"); + +-- CreateIndex +CREATE INDEX "magazzino_voce_gruppo_id_idx" ON "magazzino_voce"("gruppo_id"); -- AddForeignKey -ALTER TABLE "lista_modello_voce" ADD CONSTRAINT "lista_modello_voce_lista_modello_id_fkey" FOREIGN KEY ("lista_modello_id") REFERENCES "lista_modello"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "lista" ADD CONSTRAINT "lista_tipo_evento_id_fkey" FOREIGN KEY ("tipo_evento_id") REFERENCES "tipo_evento"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "lista_modello_voce" ADD CONSTRAINT "lista_modello_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "lista" ADD CONSTRAINT "lista_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "lista"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -112,9 +171,21 @@ ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_lista_id_fkey" FOREIGN KEY ( -- AddForeignKey ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "lista_sotto_lista" ADD CONSTRAINT "lista_sotto_lista_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lista_sotto_lista" ADD CONSTRAINT "lista_sotto_lista_sotto_lista_id_fkey" FOREIGN KEY ("sotto_lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "magazzino_voce" ADD CONSTRAINT "magazzino_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "magazzino_voce" ADD CONSTRAINT "magazzino_voce_gruppo_id_fkey" FOREIGN KEY ("gruppo_id") REFERENCES "gruppo_magazzino"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "gruppo_magazzino" ADD CONSTRAINT "gruppo_magazzino_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "gruppo_magazzino"("id") ON DELETE SET NULL ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "evento" ADD CONSTRAINT "evento_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/scouthub-magazzino-be/prisma/schema.prisma b/scouthub-magazzino-be/prisma/schema.prisma index 5c94583..f5f2c77 100644 --- a/scouthub-magazzino-be/prisma/schema.prisma +++ b/scouthub-magazzino-be/prisma/schema.prisma @@ -23,6 +23,68 @@ enum StatoMagazzinoVoce { @@map("stato_magazzino_voce") } +enum StatoLista { + bozza + privato + gruppo + pubblico + + @@map("stato_lista") +} + +enum StatoModerazioneLista { + proposto + approvato + rifiutato + + @@map("stato_moderazione_lista") +} + +enum StatoCategoria { + confermata + da_approvare + + @@map("stato_categoria") +} + +enum TipoNotifica { + MATERIALE_PROPOSTO + CATEGORIA_PROPOSTA + TIPO_EVENTO_PROPOSTO + LISTA_PROPOSTA + + @@map("tipo_notifica") +} + +// Coda di notifiche di moderazione (nuove proposte di materiale/categoria/tipo +// evento/lista in attesa di revisione): visibile solo a chi ha ruolo 'moderatore', +// non ci sono ancora notifiche personali nel dominio magazzino. +model Notifica { + id Int @id @default(autoincrement()) + tipo TipoNotifica + messaggio String + link String? + letta Boolean @default(false) + dataCreazione DateTime @default(now()) @map("data_creazione") + + @@map("notifica") +} + +// Tassonomia delle categorie di materiale, gestita dal moderatore (CRUD diretto, +// sempre creata "confermata") con un flusso di proposta aperto a qualsiasi org +// autenticata (crea in "da_approvare", poi approvata/rifiutata dal moderatore). +// Materiale.categoria resta per ora un campo stringa libero, non collegato a questa +// tabella: introdurla come FK è un passo successivo, fuori scope qui. +model Categoria { + id String @id @default(uuid()) + nome String + stato StatoCategoria @default(confermata) + creatoDaOrgId String? @map("creato_da_org_id") + creatoIl DateTime @default(now()) @map("creato_il") + + @@map("categoria") +} + model Materiale { id String @id @default(uuid()) nome String @@ -32,56 +94,67 @@ model Materiale { propostoDaOrgId String @map("proposto_da_org_id") creatoIl DateTime @default(now()) @map("creato_il") - listaModelloVoci ListaModelloVoce[] - listaVoci ListaVoce[] - magazzinoVoci MagazzinoVoce[] - eventoCheck EventoCheck[] + listaVoci ListaVoce[] + magazzinoVoci MagazzinoVoce[] + eventoCheck EventoCheck[] @@map("materiale") } model TipoEvento { - id String @id @default(uuid()) - nome String + id String @id @default(uuid()) + nome String + // Stessa semantica/enum di Categoria.stato: un tipo evento creato dal moderatore + // nasce 'confermata', uno proposto da un'organizzazione nasce 'da_approvare'. + stato StatoCategoria @default(confermata) + creatoDaOrgId String? @map("creato_da_org_id") + creatoIl DateTime @default(now()) @map("creato_il") - listeModello ListaModello[] + liste Lista[] @@map("tipo_evento") } -model ListaModello { - id String @id @default(uuid()) - nome String - tipoEventoId String @map("tipo_evento_id") - pubblica Boolean @default(true) - - tipoEvento TipoEvento @relation(fields: [tipoEventoId], references: [id]) - voci ListaModelloVoce[] - - @@map("lista_modello") -} - -model ListaModelloVoce { - listaModelloId String @map("lista_modello_id") - materialeId String @map("materiale_id") - quantita Int - - listaModello ListaModello @relation(fields: [listaModelloId], references: [id]) - materiale Materiale @relation(fields: [materialeId], references: [id]) - - @@id([listaModelloId, materialeId]) - @@map("lista_modello_voce") -} - model Lista { - id String @id @default(uuid()) - nome String - orgId String @map("org_id") - creataIl DateTime @default(now()) @map("creata_il") + id String @id @default(uuid()) + nome String + // Valorizzato solo per le liste in stato 'gruppo' (condivise con l'organizzazione): + // le liste 'bozza'/'privato'/'pubblico' sono personali e non hanno alcuna org, anche + // per un utente che ha un'organizzazione attiva sul token al momento della creazione. + orgId String? @map("org_id") + // Nullable perché le liste create prima di questo campo non hanno un autore noto; + // da qui in poi è sempre valorizzato da req.auth.userId alla creazione. + creataDaUserId String? @map("creata_da_user_id") + stato StatoLista @default(bozza) + creataIl DateTime @default(now()) @map("creata_il") + + // Moderazione, ortogonale a `stato`: nullo per bozza/privato/gruppo, valorizzato + // solo quando stato = pubblico (proposto/approvato/rifiutato, vedi liste.service.ts). + statoModerazione StatoModerazioneLista? @map("stato_moderazione") + + // Ex campo obbligatorio di ListaModello, ora opzionale su tutte le liste: serve solo + // a filtrare il catalogo pubblico per tipo evento. + tipoEventoId String? @map("tipo_evento_id") + tipoEvento TipoEvento? @relation(fields: [tipoEventoId], references: [id]) + + // Lineage self-referenziato: da quale lista pubblica approvata è stata forkata questa + // (fork = "usa come base"). Best-effort/storico: cancellare l'origine non deve + // bloccare né trascinare i figli, quindi SetNull. + parentId String? @map("parent_id") + parent Lista? @relation("ListaParent", fields: [parentId], references: [id], onDelete: SetNull) + figlie Lista[] @relation("ListaParent") voci ListaVoce[] eventi Evento[] + // Sotto-liste agganciate a questa lista personale: un solo livello di nesting + // (una sotto-lista non può avere a sua volta sotto-liste), validato in liste.service.ts. + sottoListe ListaSottoLista[] @relation("SottoListePadre") + usataComeSottoListaIn ListaSottoLista[] @relation("SottoListaFiglia") + + @@index([stato, statoModerazione]) + @@index([tipoEventoId]) + @@index([parentId]) @@map("lista") } @@ -97,6 +170,35 @@ model ListaVoce { @@map("lista_voce") } +model ListaSottoLista { + listaId String @map("lista_id") + sottoListaId String @map("sotto_lista_id") + + lista Lista @relation("SottoListePadre", fields: [listaId], references: [id]) + sottoLista Lista @relation("SottoListaFiglia", fields: [sottoListaId], references: [id]) + + @@id([listaId, sottoListaId]) + @@map("lista_sotto_lista") +} + +model GruppoMagazzino { + id String @id @default(uuid()) + orgId String @map("org_id") + nome String + // Un solo livello di nesting (uno scaffale contiene kit, un kit non contiene + // a sua volta altri gruppi), validato in gruppiMagazzino.service.ts. + parentId String? @map("parent_id") + creatoIl DateTime @default(now()) @map("creato_il") + + parent GruppoMagazzino? @relation("GruppoMagazzinoParent", fields: [parentId], references: [id], onDelete: SetNull) + figli GruppoMagazzino[] @relation("GruppoMagazzinoParent") + voci MagazzinoVoce[] + + @@index([orgId]) + @@index([parentId]) + @@map("gruppo_magazzino") +} + model MagazzinoVoce { id String @id @default(uuid()) orgId String @map("org_id") @@ -105,9 +207,14 @@ model MagazzinoVoce { stato StatoMagazzinoVoce posizione String? note String? + // Nullo finché la giacenza non viene organizzata in un gruppo (scaffale/kit): + // eliminare il gruppo non elimina la voce, la riporta solo tra le "non organizzate". + gruppoId String? @map("gruppo_id") - materiale Materiale @relation(fields: [materialeId], references: [id]) + materiale Materiale @relation(fields: [materialeId], references: [id]) + gruppo GruppoMagazzino? @relation(fields: [gruppoId], references: [id], onDelete: SetNull) + @@index([gruppoId]) @@map("magazzino_voce") } diff --git a/scouthub-magazzino-be/prisma/seed.ts b/scouthub-magazzino-be/prisma/seed.ts new file mode 100644 index 0000000..4460964 --- /dev/null +++ b/scouthub-magazzino-be/prisma/seed.ts @@ -0,0 +1,204 @@ +import { PrismaClient, StatoMateriale } from "@prisma/client"; + +const prisma = new PrismaClient(); + +// Placeholder di orgId per il materiale di catalogo seedato manualmente (non riconducibile +// a nessuna organizzazione reale), analogo a UTENTE_MODIFICA="MANUALE" nel seed di attivita-be. +const ORG_SISTEMA = "SISTEMA"; + +async function main() { + const materiali = [ + { id: "materiale-corda", nome: "Corda", categoria: "Attrezzatura", unitaMisura: "pz" }, + { id: "materiale-torcia", nome: "Torcia", categoria: "Attrezzatura", unitaMisura: "pz" }, + { + id: "materiale-kit-primo-soccorso", + nome: "Kit primo soccorso", + categoria: "Sicurezza", + unitaMisura: "kit", + }, + { id: "materiale-tenda", nome: "Tenda", categoria: "Campeggio", unitaMisura: "pz" }, + { + id: "materiale-fornello-campo", + nome: "Fornello da campo", + categoria: "Campeggio", + unitaMisura: "pz", + }, + { + id: "materiale-zaino", + nome: "Zaino", + categoria: "Equipaggiamento personale", + unitaMisura: "pz", + }, + { + id: "materiale-sacco-a-pelo", + nome: "Sacco a pelo", + categoria: "Equipaggiamento personale", + unitaMisura: "pz", + }, + { + id: "materiale-borraccia", + nome: "Borraccia", + categoria: "Equipaggiamento personale", + unitaMisura: "pz", + }, + { id: "materiale-garze", nome: "Garze", categoria: "Sicurezza", unitaMisura: "pz" }, + { id: "materiale-cerotti", nome: "Cerotti", categoria: "Sicurezza", unitaMisura: "pz" }, + { + id: "materiale-disinfettante", + nome: "Disinfettante", + categoria: "Sicurezza", + unitaMisura: "flacone", + }, + ]; + await Promise.all( + materiali.map((materiale) => + prisma.materiale.upsert({ + where: { id: materiale.id }, + update: { + ...materiale, + stato: StatoMateriale.approvato, + propostoDaOrgId: ORG_SISTEMA, + }, + create: { + ...materiale, + stato: StatoMateriale.approvato, + propostoDaOrgId: ORG_SISTEMA, + }, + }) + ) + ); + + const tipiEvento = [ + { id: "tipo-evento-uscita-giorno", nome: "Uscita di un giorno" }, + { id: "tipo-evento-campo-estivo", nome: "Campo estivo" }, + { id: "tipo-evento-riunione-settimanale", nome: "Riunione settimanale" }, + ]; + await Promise.all( + tipiEvento.map((tipoEvento) => + prisma.tipoEvento.upsert({ + where: { id: tipoEvento.id }, + update: { nome: tipoEvento.nome }, + create: tipoEvento, + }) + ) + ); + + // Le ex "liste modello" sono ora liste unificate: pubbliche, già approvate dalla + // moderazione, senza autore/org (scritte direttamente dal seed, come farebbe un + // moderatore in bypass della coda di proposte). + const listeModello = [ + { + id: "lista-modello-uscita-giorno", + nome: "Materiale base uscita giornaliera", + tipoEventoId: "tipo-evento-uscita-giorno", + }, + { + id: "lista-modello-campo-estivo", + nome: "Materiale base campo estivo", + tipoEventoId: "tipo-evento-campo-estivo", + }, + // Sotto-lista "foglia" (nessuna sotto-lista propria): allegata come sotto-lista sia a + // "uscita di un giorno" sia a "campo estivo", al posto del vecchio materiale generico + // "materiale-kit-primo-soccorso", per verificare il nesting a un livello. + { + id: "lista-modello-kit-primo-soccorso", + nome: "Kit di pronto soccorso", + tipoEventoId: "tipo-evento-uscita-giorno", + }, + ]; + for (const listaModello of listeModello) { + await prisma.lista.upsert({ + where: { id: listaModello.id }, + update: { ...listaModello, stato: "pubblico", statoModerazione: "approvato" }, + create: { + ...listaModello, + orgId: null, + creataDaUserId: null, + stato: "pubblico", + statoModerazione: "approvato", + }, + }); + } + + const listeModelloVoci = [ + { listaId: "lista-modello-uscita-giorno", materialeId: "materiale-zaino", quantita: 1 }, + { + listaId: "lista-modello-uscita-giorno", + materialeId: "materiale-borraccia", + quantita: 1, + }, + { listaId: "lista-modello-uscita-giorno", materialeId: "materiale-torcia", quantita: 1 }, + { listaId: "lista-modello-campo-estivo", materialeId: "materiale-tenda", quantita: 1 }, + { + listaId: "lista-modello-campo-estivo", + materialeId: "materiale-sacco-a-pelo", + quantita: 1, + }, + { + listaId: "lista-modello-campo-estivo", + materialeId: "materiale-fornello-campo", + quantita: 1, + }, + { listaId: "lista-modello-campo-estivo", materialeId: "materiale-corda", quantita: 2 }, + // Voci della sotto-lista "Kit di pronto soccorso" (foglia, allegata sotto a due liste diverse). + { listaId: "lista-modello-kit-primo-soccorso", materialeId: "materiale-garze", quantita: 3 }, + { + listaId: "lista-modello-kit-primo-soccorso", + materialeId: "materiale-cerotti", + quantita: 10, + }, + { + listaId: "lista-modello-kit-primo-soccorso", + materialeId: "materiale-disinfettante", + quantita: 1, + }, + ]; + + // Rimuove il vecchio materiale generico "kit primo soccorso" piazzato direttamente nelle due + // liste padre: da questo seed in poi il kit è la sotto-lista dedicata, non più una singola voce. + await prisma.listaVoce.deleteMany({ + where: { + materialeId: "materiale-kit-primo-soccorso", + listaId: { in: ["lista-modello-uscita-giorno", "lista-modello-campo-estivo"] }, + }, + }); + + for (const voce of listeModelloVoci) { + await prisma.listaVoce.upsert({ + where: { + listaId_materialeId: { + listaId: voce.listaId, + materialeId: voce.materialeId, + }, + }, + update: { quantita: voce.quantita }, + create: voce, + }); + } + + const sottoListe = [ + { listaId: "lista-modello-uscita-giorno", sottoListaId: "lista-modello-kit-primo-soccorso" }, + { listaId: "lista-modello-campo-estivo", sottoListaId: "lista-modello-kit-primo-soccorso" }, + ]; + for (const sottoLista of sottoListe) { + await prisma.listaSottoLista.upsert({ + where: { + listaId_sottoListaId: { + listaId: sottoLista.listaId, + sottoListaId: sottoLista.sottoListaId, + }, + }, + update: {}, + create: sottoLista, + }); + } +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scouthub-magazzino-be/src/app.ts b/scouthub-magazzino-be/src/app.ts index 6a2e398..9985256 100644 --- a/scouthub-magazzino-be/src/app.ts +++ b/scouthub-magazzino-be/src/app.ts @@ -2,25 +2,34 @@ import express from 'express'; import cors from 'cors'; import { healthRouter } from './routes/health.routes'; import { materialiRouter } from './routes/materiali.routes'; +import { categorieRouter } from './routes/categorie.routes'; import { tipiEventoRouter } from './routes/tipiEvento.routes'; -import { listeModelloRouter } from './routes/listeModello.routes'; import { listeRouter } from './routes/liste.routes'; import { magazzinoRouter } from './routes/magazzino.routes'; +import { gruppiMagazzinoRouter } from './routes/gruppiMagazzino.routes'; import { eventiRouter } from './routes/eventi.routes'; +import { autocompleteRouter } from './routes/autocomplete.routes'; +import { notificheRouter } from './routes/notifiche.routes'; import { errorHandler } from './middleware/errorHandler'; export const app = express(); -app.use(express.json()); +// strict: false perché l'endpoint di autocomplete (vedi autocomplete.controller.ts) accetta un +// body JSON che è una stringa "nuda" (es. `"gioco"`), non solo oggetti/array: con lo strict mode +// di default body-parser la rifiuterebbe. +app.use(express.json({ strict: false })); app.use(cors()); app.use(healthRouter); app.use(materialiRouter); +app.use(categorieRouter); app.use(tipiEventoRouter); -app.use(listeModelloRouter); app.use(listeRouter); app.use(magazzinoRouter); +app.use(gruppiMagazzinoRouter); app.use(eventiRouter); +app.use(autocompleteRouter); +app.use(notificheRouter); app.use((req, res) => { res.status(404).json({ message: 'not found' }); diff --git a/scouthub-magazzino-be/src/controllers/autocomplete.controller.ts b/scouthub-magazzino-be/src/controllers/autocomplete.controller.ts new file mode 100644 index 0000000..da435a7 --- /dev/null +++ b/scouthub-magazzino-be/src/controllers/autocomplete.controller.ts @@ -0,0 +1,16 @@ +import { Request, Response, NextFunction } from 'express'; +import { search } from '../services/autocomplete.service'; + +function extractKeyword(body: unknown): string | undefined { + return typeof body === 'string' ? body : undefined; +} + +export async function postSearch(req: Request, res: Response, next: NextFunction): Promise { + try { + const keyword = extractKeyword(req.body); + const gruppi = await search(keyword); + res.status(200).json(gruppi); + } catch (err) { + next(err); + } +} diff --git a/scouthub-magazzino-be/src/controllers/categorie.controller.ts b/scouthub-magazzino-be/src/controllers/categorie.controller.ts new file mode 100644 index 0000000..04c3cf3 --- /dev/null +++ b/scouthub-magazzino-be/src/controllers/categorie.controller.ts @@ -0,0 +1,99 @@ +import { Request, Response, NextFunction } from 'express'; +import { + aggiornaCategoria, + approvaCategoria, + creaCategoria, + eliminaCategoria, + listCategorie, + listCategorieConfermate, + proponiCategoria, + rifiutaCategoria, +} from '../services/categorie.service'; +import { HttpError } from '../errors'; + +function parseNome(body: { nome?: unknown }): string { + if (typeof body.nome !== 'string' || body.nome.trim().length === 0) { + throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota"); + } + return body.nome; +} + +export async function getCategorie(_req: Request, res: Response, next: NextFunction): Promise { + try { + const categorie = await listCategorie(); + res.status(200).json(categorie); + } catch (err) { + next(err); + } +} + +// Query pubblica, usata dall'autocomplete: mostra solo le categorie confermate, +// filtrabili per sottostringa del nome (case-insensitive). +export async function getCategoriePubbliche(req: Request, res: Response, next: NextFunction): Promise { + try { + const { nome } = req.query; + const filtroNome = typeof nome === 'string' && nome.trim().length > 0 ? nome : undefined; + + const categorie = await listCategorieConfermate(filtroNome); + res.status(200).json(categorie); + } catch (err) { + next(err); + } +} + +export async function postCategoria(req: Request, res: Response, next: NextFunction): Promise { + try { + const nome = parseNome(req.body ?? {}); + const categoria = await creaCategoria(nome); + res.status(201).json(categoria); + } catch (err) { + next(err); + } +} + +export async function putCategoria(req: Request, res: Response, next: NextFunction): Promise { + try { + const nome = parseNome(req.body ?? {}); + const categoria = await aggiornaCategoria(req.params.id, nome); + res.status(200).json(categoria); + } catch (err) { + next(err); + } +} + +export async function deleteCategoria(req: Request, res: Response, next: NextFunction): Promise { + try { + await eliminaCategoria(req.params.id); + res.status(204).send(); + } catch (err) { + next(err); + } +} + +export async function postCategoriaProposta(req: Request, res: Response, next: NextFunction): Promise { + try { + const nome = parseNome(req.body ?? {}); + const categoria = await proponiCategoria({ nome, orgId: req.auth!.orgId! }); + res.status(201).json(categoria); + } catch (err) { + next(err); + } +} + +export async function postCategoriaApprova(req: Request, res: Response, next: NextFunction): Promise { + try { + const categoria = await approvaCategoria(req.params.id); + res.status(200).json(categoria); + } catch (err) { + next(err); + } +} + +export async function postCategoriaRifiuta(req: Request, res: Response, next: NextFunction): Promise { + try { + await rifiutaCategoria(req.params.id); + res.status(204).send(); + } catch (err) { + next(err); + } +} diff --git a/scouthub-magazzino-be/src/controllers/gruppiMagazzino.controller.ts b/scouthub-magazzino-be/src/controllers/gruppiMagazzino.controller.ts new file mode 100644 index 0000000..4ff87d8 --- /dev/null +++ b/scouthub-magazzino-be/src/controllers/gruppiMagazzino.controller.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from 'express'; +import { + AggiornaGruppoInput, + CreaGruppoInput, + aggiornaGruppo, + creaGruppo, + eliminaGruppo, + listGruppiPerOrg, +} from '../services/gruppiMagazzino.service'; +import { HttpError } from '../errors'; + +interface PostGruppoBody { + nome?: unknown; + parentId?: unknown; +} + +function parseCreateBody(body: PostGruppoBody): CreaGruppoInput { + if (typeof body.nome !== 'string' || body.nome.trim().length === 0) { + throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota"); + } + if (body.parentId !== undefined && body.parentId !== null && typeof body.parentId !== 'string') { + throw new HttpError(400, "Il campo 'parentId', se presente, deve essere una stringa o null"); + } + + return { + nome: body.nome, + parentId: body.parentId as string | null | undefined, + }; +} + +interface PutGruppoBody { + nome?: unknown; + parentId?: unknown; +} + +function parseUpdateBody(body: PutGruppoBody): AggiornaGruppoInput { + if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) { + throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota"); + } + if (body.parentId !== undefined && body.parentId !== null && typeof body.parentId !== 'string') { + throw new HttpError(400, "Il campo 'parentId', se presente, deve essere una stringa o null"); + } + + return { + nome: body.nome as string | undefined, + parentId: body.parentId as string | null | undefined, + }; +} + +// L'org di appartenenza è sempre req.auth.orgId (garantito da requireOrgId a +// monte): nessun org_id letto dal body/query del client viene mai usato qui. +export async function getGruppiMagazzino(req: Request, res: Response, next: NextFunction): Promise { + try { + const gruppi = await listGruppiPerOrg(req.auth!.orgId!); + res.status(200).json(gruppi); + } catch (err) { + next(err); + } +} + +export async function postGruppoMagazzino(req: Request, res: Response, next: NextFunction): Promise { + try { + const input = parseCreateBody(req.body ?? {}); + const gruppo = await creaGruppo(req.auth!.orgId!, input); + res.status(201).json(gruppo); + } catch (err) { + next(err); + } +} + +export async function putGruppoMagazzino(req: Request, res: Response, next: NextFunction): Promise { + try { + const input = parseUpdateBody(req.body ?? {}); + const gruppo = await aggiornaGruppo(req.params.id, req.auth!.orgId!, input); + res.status(200).json(gruppo); + } catch (err) { + next(err); + } +} + +export async function deleteGruppoMagazzino(req: Request, res: Response, next: NextFunction): Promise { + try { + await eliminaGruppo(req.params.id, req.auth!.orgId!); + res.status(204).send(); + } catch (err) { + next(err); + } +} diff --git a/scouthub-magazzino-be/src/controllers/liste.controller.ts b/scouthub-magazzino-be/src/controllers/liste.controller.ts index 56dec36..977541b 100644 --- a/scouthub-magazzino-be/src/controllers/liste.controller.ts +++ b/scouthub-magazzino-be/src/controllers/liste.controller.ts @@ -1,14 +1,24 @@ +import { StatoLista } from '@prisma/client'; import { Request, Response, NextFunction } from 'express'; import { ListaVoceInput } from '../repositories/liste.repository'; import { aggiornaLista, - creaListaVuota, + creaLista, + DecisioneProposta, + decidiProposta, eliminaLista, - forkListaDaModello, - listListePerOrg, + forkListaDaLista, + listListePubbliche, + listMieListe, + listProposte, + SottoListeInput, + trovaListaPubblicaPerId, + Viewer, } from '../services/liste.service'; import { HttpError } from '../errors'; +const STATI_LISTA: StatoLista[] = ['bozza', 'privato', 'gruppo', 'pubblico']; + interface VoceBody { materialeId?: unknown; quantita?: unknown; @@ -30,6 +40,42 @@ function parseVoci(voci: unknown): ListaVoceInput[] { }); } +function parseStringArray(value: unknown, campo: string): string[] { + if (!Array.isArray(value)) { + throw new HttpError(400, `Il campo '${campo}' deve essere un array`); + } + return value.map((id: unknown) => { + if (typeof id !== 'string' || id.trim().length === 0) { + throw new HttpError(400, `Ogni elemento di '${campo}' deve essere una stringa non vuota`); + } + return id; + }); +} + +interface SottoListeBody { + sottoListeIds?: unknown; + sottoListeModelloIds?: unknown; +} + +function parseSottoListe(body: SottoListeBody): SottoListeInput { + return { + sottoListeIds: body.sottoListeIds !== undefined ? parseStringArray(body.sottoListeIds, 'sottoListeIds') : [], + sottoListeModelloIds: + body.sottoListeModelloIds !== undefined + ? parseStringArray(body.sottoListeModelloIds, 'sottoListeModelloIds') + : [], + }; +} + +// In update, sottoListe resta undefined (nessuna modifica) se il client non manda +// nessuno dei due campi — stessa semantica di update parziale già usata per 'voci'. +function parseSottoListeOpzionale(body: SottoListeBody): SottoListeInput | undefined { + if (body.sottoListeIds === undefined && body.sottoListeModelloIds === undefined) { + return undefined; + } + return parseSottoListe(body); +} + function parseNome(body: { nome?: unknown }): string { if (typeof body.nome !== 'string' || body.nome.trim().length === 0) { throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota"); @@ -37,30 +83,126 @@ function parseNome(body: { nome?: unknown }): string { return body.nome; } -// L'org di appartenenza è sempre req.auth.orgId (garantito da requireOrgId a -// monte): nessun org_id letto dal body/query del client viene mai usato qui. +// Se assente, la lista nasce come 'bozza' (stato iniziale di default nel wizard di creazione). +function parseStato(body: { stato?: unknown }): StatoLista { + if (body.stato === undefined) { + return 'bozza'; + } + if (typeof body.stato !== 'string' || !STATI_LISTA.includes(body.stato as StatoLista)) { + throw new HttpError(400, `Il campo 'stato', se presente, deve essere uno tra: ${STATI_LISTA.join(', ')}`); + } + return body.stato as StatoLista; +} + +function parseStatoOpzionale(body: { stato?: unknown }): StatoLista | undefined { + if (body.stato === undefined) { + return undefined; + } + if (typeof body.stato !== 'string' || !STATI_LISTA.includes(body.stato as StatoLista)) { + throw new HttpError(400, `Il campo 'stato', se presente, deve essere uno tra: ${STATI_LISTA.join(', ')}`); + } + return body.stato as StatoLista; +} + +function parseTipoEventoIdOpzionale(body: { tipoEventoId?: unknown }): string | undefined { + if (body.tipoEventoId === undefined) { + return undefined; + } + if (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0) { + throw new HttpError(400, "Il campo 'tipoEventoId', se presente, deve essere una stringa non vuota"); + } + return body.tipoEventoId; +} + +// req.auth.orgId può essere null (utente senza organizzazione attiva): le liste +// personali (bozza/privato/pubblico) non ne richiedono una, solo quelle 'gruppo'. +function viewerOf(req: Request): Viewer { + return { userId: req.auth!.userId, orgId: req.auth!.orgId ?? null, roles: req.auth!.roles }; +} + +// "Le mie liste": le proprie (qualunque stato) + le liste 'gruppo' della propria org, +// create da chiunque nell'org. export async function getListe(req: Request, res: Response, next: NextFunction): Promise { try { - const liste = await listListePerOrg(req.auth!.orgId!); + const liste = await listMieListe(viewerOf(req)); res.status(200).json(liste); } catch (err) { next(err); } } +// Nessuna autenticazione richiesta: le liste 'pubblico' già approvate sono visibili a +// chiunque. Filtro opzionale per tipo evento, mirror del vecchio GET /liste-modello. +export async function getListePubbliche(req: Request, res: Response, next: NextFunction): Promise { + try { + const { tipoEventoId } = req.query; + const filtro = typeof tipoEventoId === 'string' && tipoEventoId.trim().length > 0 ? tipoEventoId : undefined; + const liste = await listListePubbliche(filtro); + res.status(200).json(liste); + } catch (err) { + next(err); + } +} + +// Dettaglio pubblico per singolo id, mirror del vecchio GET /liste-modello/:id. +export async function getListaPubblicaById(req: Request, res: Response, next: NextFunction): Promise { + try { + const lista = await trovaListaPubblicaPerId(req.params.id); + res.status(200).json(lista); + } catch (err) { + next(err); + } +} + +// Coda di moderazione: solo admin/moderatore (vedi routes). +export async function getListeProposte(_req: Request, res: Response, next: NextFunction): Promise { + try { + const proposte = await listProposte(); + res.status(200).json(proposte); + } catch (err) { + next(err); + } +} + +interface PatchPropostaBody { + decisione?: unknown; +} + +function parseDecisioneBody(body: PatchPropostaBody): DecisioneProposta { + if (body.decisione !== 'approvato' && body.decisione !== 'rifiutato') { + throw new HttpError(400, "Il campo 'decisione' deve valere 'approvato' o 'rifiutato'"); + } + return body.decisione; +} + +export async function patchListaProposta(req: Request, res: Response, next: NextFunction): Promise { + try { + const decisione = parseDecisioneBody(req.body ?? {}); + const lista = await decidiProposta(req.params.id, decisione); + res.status(200).json(lista); + } catch (err) { + next(err); + } +} + export async function postLista(req: Request, res: Response, next: NextFunction): Promise { try { - const nome = parseNome(req.body ?? {}); - const lista = await creaListaVuota(req.auth!.orgId!, nome); + const body = req.body ?? {}; + const nome = parseNome(body); + const stato = parseStato(body); + const voci = body.voci !== undefined ? parseVoci(body.voci) : []; + const sottoListe = parseSottoListe(body); + const tipoEventoId = parseTipoEventoIdOpzionale(body); + const lista = await creaLista(nome, stato, voci, sottoListe, viewerOf(req), tipoEventoId); res.status(201).json(lista); } catch (err) { next(err); } } -export async function postListaDaModello(req: Request, res: Response, next: NextFunction): Promise { +export async function postListaDaFork(req: Request, res: Response, next: NextFunction): Promise { try { - const lista = await forkListaDaModello(req.auth!.orgId!, req.params.listaModelloId); + const lista = await forkListaDaLista(req.params.id, viewerOf(req)); res.status(201).json(lista); } catch (err) { next(err); @@ -69,24 +211,56 @@ export async function postListaDaModello(req: Request, res: Response, next: Next interface PutListaBody { nome?: unknown; + stato?: unknown; voci?: unknown; + sottoListeIds?: unknown; + sottoListeModelloIds?: unknown; + tipoEventoId?: unknown; } -function parseUpdateBody(body: PutListaBody): { nome?: string; voci?: ListaVoceInput[] } { +// A differenza di parseTipoEventoIdOpzionale (solo creazione, dove 'non presente' è +// l'unico modo per dire 'nessun tipo evento'), in update body.tipoEventoId: null è un +// valore esplicito ('rimuovi il tipo evento associato'), distinto da undefined ('non +// toccare il campo'). +function parseTipoEventoIdUpdate(body: { tipoEventoId?: unknown }): string | null | undefined { + if (body.tipoEventoId === undefined) { + return undefined; + } + if (body.tipoEventoId === null) { + return null; + } + if (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0) { + throw new HttpError(400, "Il campo 'tipoEventoId', se presente, deve essere una stringa non vuota o null"); + } + return body.tipoEventoId; +} + +function parseUpdateBody( + body: PutListaBody, +): { + nome?: string; + stato?: StatoLista; + voci?: ListaVoceInput[]; + sottoListe?: SottoListeInput; + tipoEventoId?: string | null; +} { if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) { throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota"); } return { nome: body.nome as string | undefined, + stato: parseStatoOpzionale(body), voci: body.voci !== undefined ? parseVoci(body.voci) : undefined, + sottoListe: parseSottoListeOpzionale(body), + tipoEventoId: parseTipoEventoIdUpdate(body), }; } export async function putLista(req: Request, res: Response, next: NextFunction): Promise { try { const input = parseUpdateBody(req.body ?? {}); - const lista = await aggiornaLista(req.params.id, req.auth!.orgId!, input); + const lista = await aggiornaLista(req.params.id, input, viewerOf(req)); res.status(200).json(lista); } catch (err) { next(err); @@ -95,7 +269,7 @@ export async function putLista(req: Request, res: Response, next: NextFunction): export async function deleteLista(req: Request, res: Response, next: NextFunction): Promise { try { - await eliminaLista(req.params.id, req.auth!.orgId!); + await eliminaLista(req.params.id, viewerOf(req)); res.status(204).send(); } catch (err) { next(err); diff --git a/scouthub-magazzino-be/src/controllers/listeModello.controller.ts b/scouthub-magazzino-be/src/controllers/listeModello.controller.ts deleted file mode 100644 index b2454e6..0000000 --- a/scouthub-magazzino-be/src/controllers/listeModello.controller.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { ListaModelloVoceInput } from '../repositories/listeModello.repository'; -import { - aggiornaListaModello, - creaListaModello, - eliminaListaModello, - listListeModello, -} from '../services/listeModello.service'; -import { HttpError } from '../errors'; - -interface VoceBody { - materialeId?: unknown; - quantita?: unknown; -} - -function parseVoci(voci: unknown): ListaModelloVoceInput[] { - if (!Array.isArray(voci)) { - throw new HttpError(400, "Il campo 'voci' deve essere un array"); - } - - return voci.map((voce: VoceBody) => { - if (typeof voce.materialeId !== 'string' || voce.materialeId.trim().length === 0) { - throw new HttpError(400, "Ogni voce deve avere un 'materialeId' valido"); - } - if (typeof voce.quantita !== 'number' || !Number.isInteger(voce.quantita) || voce.quantita <= 0) { - throw new HttpError(400, "Ogni voce deve avere una 'quantita' intera positiva"); - } - return { materialeId: voce.materialeId, quantita: voce.quantita }; - }); -} - -interface PostListaModelloBody { - nome?: unknown; - tipoEventoId?: unknown; - voci?: unknown; -} - -function parseCreateBody(body: PostListaModelloBody): { nome: string; tipoEventoId: string; voci: ListaModelloVoceInput[] } { - if (typeof body.nome !== 'string' || body.nome.trim().length === 0) { - throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota"); - } - if (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0) { - throw new HttpError(400, "Il campo 'tipoEventoId' è obbligatorio ed è una stringa non vuota"); - } - - return { nome: body.nome, tipoEventoId: body.tipoEventoId, voci: parseVoci(body.voci ?? []) }; -} - -interface PutListaModelloBody { - nome?: unknown; - tipoEventoId?: unknown; - voci?: unknown; -} - -function parseUpdateBody(body: PutListaModelloBody): { nome?: string; tipoEventoId?: string; voci?: ListaModelloVoceInput[] } { - if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) { - throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota"); - } - if (body.tipoEventoId !== undefined && (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0)) { - throw new HttpError(400, "Il campo 'tipoEventoId', se presente, deve essere una stringa non vuota"); - } - - return { - nome: body.nome as string | undefined, - tipoEventoId: body.tipoEventoId as string | undefined, - voci: body.voci !== undefined ? parseVoci(body.voci) : undefined, - }; -} - -// Query pubblica: unico filtro accettato è "tipoEventoId". "pubblica" non è mai -// un parametro esposto al client: le liste modello sono per definizione pubbliche. -export async function getListeModello(req: Request, res: Response, next: NextFunction): Promise { - try { - const { tipoEventoId } = req.query; - const filtro = typeof tipoEventoId === 'string' && tipoEventoId.trim().length > 0 ? tipoEventoId : undefined; - - const liste = await listListeModello(filtro); - res.status(200).json(liste); - } catch (err) { - next(err); - } -} - -export async function postListaModello(req: Request, res: Response, next: NextFunction): Promise { - try { - const input = parseCreateBody(req.body ?? {}); - const lista = await creaListaModello(input); - res.status(201).json(lista); - } catch (err) { - next(err); - } -} - -export async function putListaModello(req: Request, res: Response, next: NextFunction): Promise { - try { - const input = parseUpdateBody(req.body ?? {}); - const lista = await aggiornaListaModello(req.params.id, input); - res.status(200).json(lista); - } catch (err) { - next(err); - } -} - -export async function deleteListaModello(req: Request, res: Response, next: NextFunction): Promise { - try { - await eliminaListaModello(req.params.id); - res.status(204).send(); - } catch (err) { - next(err); - } -} diff --git a/scouthub-magazzino-be/src/controllers/magazzino.controller.ts b/scouthub-magazzino-be/src/controllers/magazzino.controller.ts index 4b17105..cc80c20 100644 --- a/scouthub-magazzino-be/src/controllers/magazzino.controller.ts +++ b/scouthub-magazzino-be/src/controllers/magazzino.controller.ts @@ -15,6 +15,7 @@ interface PostVoceBody { stato?: unknown; posizione?: unknown; note?: unknown; + gruppoId?: unknown; } function parseCreateBody(body: PostVoceBody): AggiungiVoceInput { @@ -33,6 +34,9 @@ function parseCreateBody(body: PostVoceBody): AggiungiVoceInput { if (body.note !== undefined && typeof body.note !== 'string') { throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa"); } + if (body.gruppoId !== undefined && body.gruppoId !== null && typeof body.gruppoId !== 'string') { + throw new HttpError(400, "Il campo 'gruppoId', se presente, deve essere una stringa o null"); + } return { materialeId: body.materialeId, @@ -40,6 +44,7 @@ function parseCreateBody(body: PostVoceBody): AggiungiVoceInput { stato: body.stato, posizione: body.posizione as string | undefined, note: body.note as string | undefined, + gruppoId: body.gruppoId as string | null | undefined, }; } @@ -49,6 +54,7 @@ interface PutVoceBody { stato?: unknown; posizione?: unknown; note?: unknown; + gruppoId?: unknown; } function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput { @@ -70,6 +76,9 @@ function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput { if (body.note !== undefined && body.note !== null && typeof body.note !== 'string') { throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa o null"); } + if (body.gruppoId !== undefined && body.gruppoId !== null && typeof body.gruppoId !== 'string') { + throw new HttpError(400, "Il campo 'gruppoId', se presente, deve essere una stringa o null"); + } return { materialeId: body.materialeId as string | undefined, @@ -77,6 +86,7 @@ function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput { stato: body.stato as StatoMagazzinoVoce | undefined, posizione: body.posizione as string | null | undefined, note: body.note as string | null | undefined, + gruppoId: body.gruppoId as string | null | undefined, }; } diff --git a/scouthub-magazzino-be/src/controllers/materiali.controller.ts b/scouthub-magazzino-be/src/controllers/materiali.controller.ts index e283e82..06b9021 100644 --- a/scouthub-magazzino-be/src/controllers/materiali.controller.ts +++ b/scouthub-magazzino-be/src/controllers/materiali.controller.ts @@ -1,20 +1,23 @@ import { Request, Response, NextFunction } from 'express'; import { DecisioneProposta, + aggiornaMateriale, + creaMateriale, decidiProposta, + eliminaMateriale, listMaterialiApprovati, listProposte, proponiMateriale, } from '../services/materiali.service'; import { HttpError } from '../errors'; -interface PostPropostaBody { +interface MaterialeBody { nome?: unknown; categoria?: unknown; unitaMisura?: unknown; } -function parsePropostaBody(body: PostPropostaBody): { nome: string; categoria: string; unitaMisura: string } { +function parseMaterialeBody(body: MaterialeBody): { nome: string; categoria: string; unitaMisura: string } { if (typeof body.nome !== 'string' || body.nome.trim().length === 0) { throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota"); } @@ -39,15 +42,16 @@ function parseDecisioneBody(body: PatchPropostaBody): DecisioneProposta { return body.decisione; } -// Query pubblica: unico filtro accettato dal client è "categoria". Lo stato -// non è mai un parametro esposto: il catalogo pubblico mostra solo i -// materiali con stato "approvato". +// Query pubblica: filtri accettati dal client sono "categoria" e "nome" (ricerca +// per sottostringa, case-insensitive, usata dall'autocomplete). Lo stato non è mai +// un parametro esposto: il catalogo pubblico mostra solo i materiali con stato "approvato". export async function getMaterialiPubblici(req: Request, res: Response, next: NextFunction): Promise { try { - const { categoria } = req.query; - const filtro = typeof categoria === 'string' && categoria.trim().length > 0 ? categoria : undefined; + const { categoria, nome } = req.query; + const filtroCategoria = typeof categoria === 'string' && categoria.trim().length > 0 ? categoria : undefined; + const filtroNome = typeof nome === 'string' && nome.trim().length > 0 ? nome : undefined; - const materiali = await listMaterialiApprovati(filtro); + const materiali = await listMaterialiApprovati(filtroCategoria, filtroNome); res.status(200).json(materiali); } catch (err) { next(err); @@ -56,7 +60,7 @@ export async function getMaterialiPubblici(req: Request, res: Response, next: Ne export async function postProposta(req: Request, res: Response, next: NextFunction): Promise { try { - const input = parsePropostaBody(req.body ?? {}); + const input = parseMaterialeBody(req.body ?? {}); const proposta = await proponiMateriale({ ...input, orgId: req.auth!.orgId! }); res.status(201).json(proposta); } catch (err) { @@ -64,6 +68,35 @@ export async function postProposta(req: Request, res: Response, next: NextFuncti } } +export async function postMateriale(req: Request, res: Response, next: NextFunction): Promise { + try { + const input = parseMaterialeBody(req.body ?? {}); + const materiale = await creaMateriale({ ...input, orgId: req.auth!.orgId! }); + res.status(201).json(materiale); + } catch (err) { + next(err); + } +} + +export async function putMateriale(req: Request, res: Response, next: NextFunction): Promise { + try { + const input = parseMaterialeBody(req.body ?? {}); + const materiale = await aggiornaMateriale(req.params.id, input); + res.status(200).json(materiale); + } catch (err) { + next(err); + } +} + +export async function deleteMateriale(req: Request, res: Response, next: NextFunction): Promise { + try { + await eliminaMateriale(req.params.id); + res.status(204).send(); + } catch (err) { + next(err); + } +} + export async function getProposte(_req: Request, res: Response, next: NextFunction): Promise { try { const proposte = await listProposte(); diff --git a/scouthub-magazzino-be/src/controllers/notifiche.controller.ts b/scouthub-magazzino-be/src/controllers/notifiche.controller.ts new file mode 100644 index 0000000..12fd37d --- /dev/null +++ b/scouthub-magazzino-be/src/controllers/notifiche.controller.ts @@ -0,0 +1,45 @@ +import { NextFunction, Request, Response } from 'express'; +import { HttpError } from '../errors'; +import { countNonLette, listNotifiche, segnaLetta, segnaTutteLette } from '../services/notifiche.service'; + +function parseId(req: Request): number { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + throw new HttpError(400, "Il campo 'id' deve essere un numero intero"); + } + return id; +} + +export async function getNotifiche(_req: Request, res: Response, next: NextFunction): Promise { + try { + res.status(200).json(await listNotifiche()); + } catch (err) { + next(err); + } +} + +export async function getCountNonLette(_req: Request, res: Response, next: NextFunction): Promise { + try { + res.status(200).json({ count: await countNonLette() }); + } catch (err) { + next(err); + } +} + +export async function putLetta(req: Request, res: Response, next: NextFunction): Promise { + try { + await segnaLetta(parseId(req)); + res.status(204).send(); + } catch (err) { + next(err); + } +} + +export async function putLetteTutte(_req: Request, res: Response, next: NextFunction): Promise { + try { + await segnaTutteLette(); + res.status(204).send(); + } catch (err) { + next(err); + } +} diff --git a/scouthub-magazzino-be/src/controllers/tipiEvento.controller.ts b/scouthub-magazzino-be/src/controllers/tipiEvento.controller.ts index fa05461..914feac 100644 --- a/scouthub-magazzino-be/src/controllers/tipiEvento.controller.ts +++ b/scouthub-magazzino-be/src/controllers/tipiEvento.controller.ts @@ -1,5 +1,14 @@ import { Request, Response, NextFunction } from 'express'; -import { aggiornaTipoEvento, creaTipoEvento, eliminaTipoEvento, listTipiEvento } from '../services/tipiEvento.service'; +import { + aggiornaTipoEvento, + approvaTipoEvento, + creaTipoEvento, + eliminaTipoEvento, + listTipiEvento, + listTipiEventoConfermati, + proponiTipoEvento, + rifiutaTipoEvento, +} from '../services/tipiEvento.service'; import { HttpError } from '../errors'; function parseNome(body: { nome?: unknown }): string { @@ -9,7 +18,22 @@ function parseNome(body: { nome?: unknown }): string { return body.nome; } -export async function getTipiEvento(_req: Request, res: Response, next: NextFunction): Promise { +// Pubblica: usata sia dal filtro del catalogo liste sia dall'autocomplete nel wizard +// di creazione lista. Mostra solo i tipi evento confermati. +export async function getTipiEvento(req: Request, res: Response, next: NextFunction): Promise { + try { + const { nome } = req.query; + const filtroNome = typeof nome === 'string' && nome.trim().length > 0 ? nome : undefined; + + const tipiEvento = await listTipiEventoConfermati(filtroNome); + res.status(200).json(tipiEvento); + } catch (err) { + next(err); + } +} + +// Moderazione: tutti gli stati, incluse le proposte in attesa di approvazione. +export async function getTipiEventoModerazione(_req: Request, res: Response, next: NextFunction): Promise { try { const tipiEvento = await listTipiEvento(); res.status(200).json(tipiEvento); @@ -28,6 +52,16 @@ export async function postTipoEvento(req: Request, res: Response, next: NextFunc } } +export async function postTipoEventoProposta(req: Request, res: Response, next: NextFunction): Promise { + try { + const nome = parseNome(req.body ?? {}); + const tipoEvento = await proponiTipoEvento({ nome, orgId: req.auth!.orgId! }); + res.status(201).json(tipoEvento); + } catch (err) { + next(err); + } +} + export async function putTipoEvento(req: Request, res: Response, next: NextFunction): Promise { try { const nome = parseNome(req.body ?? {}); @@ -46,3 +80,21 @@ export async function deleteTipoEvento(req: Request, res: Response, next: NextFu next(err); } } + +export async function postTipoEventoApprova(req: Request, res: Response, next: NextFunction): Promise { + try { + const tipoEvento = await approvaTipoEvento(req.params.id); + res.status(200).json(tipoEvento); + } catch (err) { + next(err); + } +} + +export async function postTipoEventoRifiuta(req: Request, res: Response, next: NextFunction): Promise { + try { + await rifiutaTipoEvento(req.params.id); + res.status(204).send(); + } catch (err) { + next(err); + } +} diff --git a/scouthub-magazzino-be/src/repositories/categorie.repository.ts b/scouthub-magazzino-be/src/repositories/categorie.repository.ts new file mode 100644 index 0000000..2356df2 --- /dev/null +++ b/scouthub-magazzino-be/src/repositories/categorie.repository.ts @@ -0,0 +1,46 @@ +import { Categoria, StatoCategoria } from '@prisma/client'; +import { prisma } from '../db/prisma'; + +export interface CreateCategoriaData { + nome: string; + stato: StatoCategoria; + creatoDaOrgId?: string | null; +} + +export class CategorieRepository { + findAll(): Promise { + return prisma.categoria.findMany({ orderBy: { nome: 'asc' } }); + } + + findConfermate(nome?: string): Promise { + return prisma.categoria.findMany({ + where: { + stato: StatoCategoria.confermata, + ...(nome ? { nome: { contains: nome, mode: 'insensitive' } } : {}), + }, + orderBy: { nome: 'asc' }, + }); + } + + findById(id: string): Promise { + return prisma.categoria.findUnique({ where: { id } }); + } + + create(data: CreateCategoriaData): Promise { + return prisma.categoria.create({ data }); + } + + update(id: string, nome: string): Promise { + return prisma.categoria.update({ where: { id }, data: { nome } }); + } + + updateStato(id: string, stato: StatoCategoria): Promise { + return prisma.categoria.update({ where: { id }, data: { stato } }); + } + + async delete(id: string): Promise { + await prisma.categoria.delete({ where: { id } }); + } +} + +export const categorieRepository = new CategorieRepository(); diff --git a/scouthub-magazzino-be/src/repositories/gruppiMagazzino.repository.ts b/scouthub-magazzino-be/src/repositories/gruppiMagazzino.repository.ts new file mode 100644 index 0000000..6c7ac62 --- /dev/null +++ b/scouthub-magazzino-be/src/repositories/gruppiMagazzino.repository.ts @@ -0,0 +1,60 @@ +import { Prisma } from '@prisma/client'; +import { prisma } from '../db/prisma'; + +const includeAlbero = { + voci: { include: { materiale: true } }, + figli: { include: { voci: { include: { materiale: true } } } }, +} satisfies Prisma.GruppoMagazzinoInclude; + +export type GruppoMagazzinoConAlbero = Prisma.GruppoMagazzinoGetPayload<{ include: typeof includeAlbero }>; + +export interface CreateGruppoMagazzinoData { + orgId: string; + nome: string; + parentId?: string | null; +} + +export interface UpdateGruppoMagazzinoData { + nome?: string; + parentId?: string | null; +} + +export class GruppiMagazzinoRepository { + // Solo i gruppi radice (parentId null): i figli arrivano annidati via include, + // un solo livello come da vincolo applicativo. + findRadiceByOrg(orgId: string): Promise { + return prisma.gruppoMagazzino.findMany({ + where: { orgId, parentId: null }, + include: includeAlbero, + orderBy: { creatoIl: 'asc' }, + }); + } + + findByIdAndOrg(id: string, orgId: string): Promise { + return prisma.gruppoMagazzino.findFirst({ where: { id, orgId }, include: includeAlbero }); + } + + // Usato per validare che un parentId indicato esista, sia della stessa org e sia + // esso stesso un gruppo radice (nessun nesting a più di un livello). + findRadiceByIdAndOrg(id: string, orgId: string): Promise<{ id: string } | null> { + return prisma.gruppoMagazzino.findFirst({ where: { id, orgId, parentId: null }, select: { id: true } }); + } + + countFigli(id: string): Promise { + return prisma.gruppoMagazzino.count({ where: { parentId: id } }); + } + + create(data: CreateGruppoMagazzinoData): Promise { + return prisma.gruppoMagazzino.create({ data, include: includeAlbero }); + } + + update(id: string, data: UpdateGruppoMagazzinoData): Promise { + return prisma.gruppoMagazzino.update({ where: { id }, data, include: includeAlbero }); + } + + async delete(id: string): Promise { + await prisma.gruppoMagazzino.delete({ where: { id } }); + } +} + +export const gruppiMagazzinoRepository = new GruppiMagazzinoRepository(); diff --git a/scouthub-magazzino-be/src/repositories/liste.repository.ts b/scouthub-magazzino-be/src/repositories/liste.repository.ts index 7343712..714b830 100644 --- a/scouthub-magazzino-be/src/repositories/liste.repository.ts +++ b/scouthub-magazzino-be/src/repositories/liste.repository.ts @@ -1,8 +1,16 @@ -import { Prisma } from '@prisma/client'; +import { Prisma, PrismaClient, StatoLista, StatoModerazioneLista } from '@prisma/client'; import { prisma } from '../db/prisma'; +// Alcuni metodi vengono invocati sia standalone sia dentro una transazione orchestrata +// dal service (fork-e-aggancio atomico delle sotto-liste, vedi liste.service.ts): il +// chiamante può passare il client di transazione al posto del PrismaClient globale. +type Db = PrismaClient | Prisma.TransactionClient; + const includeVoci = { voci: { include: { materiale: true } }, + // Le sotto-liste sono a un solo livello: qui basta espandere le voci materiale della + // sotto-lista, non le sue eventuali (vietate) sotto-liste. + sottoListe: { include: { sottoLista: { include: { voci: { include: { materiale: true } } } } } }, } satisfies Prisma.ListaInclude; export type ListaConVoci = Prisma.ListaGetPayload<{ include: typeof includeVoci }>; @@ -14,66 +22,214 @@ export interface ListaVoceInput { export interface CreateListaData { nome: string; - orgId: string; + // Valorizzato solo per le liste 'gruppo': per bozza/privato/pubblico è sempre null, + // anche se il chiamante ha un'org attiva (vedi liste.service.ts::risolviOrgId). + orgId: string | null; + creataDaUserId: string; + stato: StatoLista; + // Nullo per bozza/privato/gruppo, valorizzato solo se stato='pubblico' (vedi + // liste.service.ts::risolviStatoModerazione). + statoModerazione: StatoModerazioneLista | null; + tipoEventoId?: string | null; + // Da quale lista pubblica approvata è stata forkata questa (fork = "usa come base"). + parentId?: string | null; voci: ListaVoceInput[]; + sottoListeIds: string[]; } export interface UpdateListaData { nome?: string; + stato?: StatoLista; + orgId?: string | null; + statoModerazione?: StatoModerazioneLista | null; + tipoEventoId?: string | null; voci?: ListaVoceInput[]; + sottoListeIds?: string[]; } export class ListeRepository { - findAllByOrg(orgId: string): Promise { + // "Visibili" a un utente: le proprie liste (qualunque stato) + le liste 'gruppo' + // della sua organizzazione attiva, create da chiunque nell'org (comportamento + // collaborativo: una lista di gruppo è condivisa da tutti i membri, non solo da chi + // l'ha creata). Se l'utente non ha un'org attiva, solo le proprie. + findVisibiliPerUtente(userId: string, orgId: string | null): Promise { return prisma.lista.findMany({ - where: { orgId }, + where: { + OR: [{ creataDaUserId: userId }, ...(orgId ? [{ stato: 'gruppo' as StatoLista, orgId }] : [])], + }, include: includeVoci, orderBy: { creataIl: 'desc' }, }); } - // id + orgId nella stessa where: una lista di un'altra org risulta - // semplicemente "non trovata", mai un 403 che ne rivela l'esistenza. + // Catalogo pubblico: liste 'pubblico' E già approvate dalla moderazione. Sostituisce + // sia il vecchio findPubbliche() (che filtrava solo stato) sia ListeModelloRepository + // .findAll() (che filtrava solo pubblica:true) — ora un'unica condizione a due assi. + findApprovatePubbliche(tipoEventoId?: string): Promise { + return prisma.lista.findMany({ + where: { + stato: 'pubblico', + statoModerazione: 'approvato', + ...(tipoEventoId ? { tipoEventoId } : {}), + }, + include: includeVoci, + orderBy: { creataIl: 'desc' }, + }); + } + + // Dettaglio pubblico per singolo id (usato dal deep-link al catalogo e dal fork): + // stesso filtro a due assi di findApprovatePubbliche, ma per un id preciso. + findApprovataPerId(id: string, db: Db = prisma): Promise { + return db.lista.findFirst({ + where: { id, stato: 'pubblico', statoModerazione: 'approvato' }, + include: includeVoci, + }); + } + + // Coda di moderazione: liste 'pubblico' ancora in attesa di decisione, FIFO per data + // di creazione — mirror di materiali.repository.ts::findProposte. + findProposte(): Promise { + return prisma.lista.findMany({ + where: { stato: 'pubblico', statoModerazione: 'proposto' }, + include: includeVoci, + orderBy: { creataIl: 'asc' }, + }); + } + + // Unica transizione di stato di moderazione, mirror di + // materiali.repository.ts::updateStato. + updateStatoModerazione(id: string, statoModerazione: StatoModerazioneLista): Promise { + return prisma.lista.update({ + where: { id }, + data: { statoModerazione }, + include: includeVoci, + }); + } + + // Lookup senza alcun filtro di org: il controllo di chi può gestire questa lista + // (creatore, o membro dell'org se è di gruppo) è applicativo, vedi + // liste.service.ts::assicuraAccessoGestione. + findById(id: string): Promise { + return prisma.lista.findFirst({ where: { id }, include: includeVoci }); + } + + // Usato solo da eventi.service.ts (un Evento, sempre org-scoped, può collegarsi solo + // a una lista 'gruppo' della propria org: le altre liste hanno orgId null e non + // potranno mai combaciare). Non toccare la firma: è un contratto tra i due moduli. findByIdAndOrg(id: string, orgId: string): Promise { return prisma.lista.findFirst({ where: { id, orgId }, include: includeVoci }); } - create(data: CreateListaData): Promise { - return prisma.lista.create({ + create(data: CreateListaData, db: Db = prisma): Promise { + return db.lista.create({ data: { nome: data.nome, orgId: data.orgId, + creataDaUserId: data.creataDaUserId, + stato: data.stato, + statoModerazione: data.statoModerazione, + tipoEventoId: data.tipoEventoId ?? null, + parentId: data.parentId ?? null, voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) }, + sottoListe: { create: data.sottoListeIds.map((sottoListaId) => ({ sottoListaId })) }, }, include: includeVoci, }); } - update(id: string, data: UpdateListaData): Promise { - return prisma.$transaction(async (tx) => { - if (data.voci) { - await tx.listaVoce.deleteMany({ where: { listaId: id } }); - } + // Se il chiamante passa già un client di transazione (orchestrazione a monte nel + // service), le due operazioni (delete + update) vengono semplicemente eseguite su + // quel client, atomiche insieme al resto della transazione esterna. Se invece non + // viene passato nulla, questo metodo resta atomico da solo aprendo la propria + // transazione, come faceva prima di introdurre le sotto-liste. + update(id: string, data: UpdateListaData, db?: Db): Promise { + if (db) { + return this.eseguiUpdate(db, id, data); + } + return prisma.$transaction((tx) => this.eseguiUpdate(tx, id, data)); + } - return tx.lista.update({ - where: { id }, - data: { - ...(data.nome !== undefined ? { nome: data.nome } : {}), - ...(data.voci - ? { voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) } } - : {}), - }, - include: includeVoci, - }); + private async eseguiUpdate(db: Db, id: string, data: UpdateListaData): Promise { + if (data.voci) { + await db.listaVoce.deleteMany({ where: { listaId: id } }); + } + if (data.sottoListeIds) { + await db.listaSottoLista.deleteMany({ where: { listaId: id } }); + } + + return db.lista.update({ + where: { id }, + data: { + ...(data.nome !== undefined ? { nome: data.nome } : {}), + ...(data.stato !== undefined ? { stato: data.stato } : {}), + ...(data.orgId !== undefined ? { orgId: data.orgId } : {}), + ...(data.statoModerazione !== undefined ? { statoModerazione: data.statoModerazione } : {}), + ...(data.tipoEventoId !== undefined ? { tipoEventoId: data.tipoEventoId } : {}), + ...(data.voci + ? { voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) } } + : {}), + ...(data.sottoListeIds + ? { sottoListe: { create: data.sottoListeIds.map((sottoListaId) => ({ sottoListaId })) } } + : {}), + }, + include: includeVoci, }); } async delete(id: string): Promise { await prisma.$transaction(async (tx) => { await tx.listaVoce.deleteMany({ where: { listaId: id } }); + // Solo i propri agganci come padre: MAI quelli come figlio (sottoListaId: id), + // altrimenti si aggirerebbe il vincolo RESTRICT che impedisce di eliminare una + // lista ancora usata come sotto-lista altrove. + await tx.listaSottoLista.deleteMany({ where: { listaId: id } }); await tx.lista.delete({ where: { id } }); }); } + + // Tra gli id candidati, quali sono utilizzabili come sotto-lista per l'utente + // corrente: proprie liste (qualunque stato), liste 'gruppo' della stessa org (solo se + // permettiGruppo, cioè la lista padre è essa stessa 'gruppo'), oppure liste pubbliche + // già approvate (agganciabili da chiunque, come le ex-ListaModello). Una lista + // personale può quindi agganciare solo proprie liste personali + liste pubbliche; una + // lista di gruppo può agganciare anche liste di gruppo della sua org. + async trovaVisibiliComeSottoLista( + userId: string, + orgId: string | null, + permettiGruppo: boolean, + ids: string[], + db: Db = prisma, + ): Promise { + if (ids.length === 0) { + return []; + } + const righe = await db.lista.findMany({ + where: { + id: { in: ids }, + OR: [ + { creataDaUserId: userId }, + ...(permettiGruppo && orgId ? [{ stato: 'gruppo' as StatoLista, orgId }] : []), + { stato: 'pubblico' as StatoLista, statoModerazione: 'approvato' as StatoModerazioneLista }, + ], + }, + select: { id: true }, + }); + return righe.map((r) => r.id); + } + + // Strutturale, non dipende dall'org: la visibilità/autorizzazione è già stata + // verificata da trovaVisibiliComeSottoLista. + async trovaConSottoListe(ids: string[], db: Db = prisma): Promise { + if (ids.length === 0) { + return []; + } + const righe = await db.listaSottoLista.findMany({ + where: { listaId: { in: ids } }, + select: { listaId: true }, + distinct: ['listaId'], + }); + return righe.map((r) => r.listaId); + } } export const listeRepository = new ListeRepository(); diff --git a/scouthub-magazzino-be/src/repositories/listeModello.repository.ts b/scouthub-magazzino-be/src/repositories/listeModello.repository.ts deleted file mode 100644 index dd957ed..0000000 --- a/scouthub-magazzino-be/src/repositories/listeModello.repository.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { prisma } from '../db/prisma'; - -const includeVoci = { - voci: { include: { materiale: true } }, -} satisfies Prisma.ListaModelloInclude; - -export type ListaModelloConVoci = Prisma.ListaModelloGetPayload<{ include: typeof includeVoci }>; - -export interface ListaModelloVoceInput { - materialeId: string; - quantita: number; -} - -export interface CreateListaModelloData { - nome: string; - tipoEventoId: string; - voci: ListaModelloVoceInput[]; -} - -export interface UpdateListaModelloData { - nome?: string; - tipoEventoId?: string; - voci?: ListaModelloVoceInput[]; -} - -export class ListeModelloRepository { - // "pubblica" è sempre true per le liste modello: non è un filtro opzionale, - // è la condizione fissa che qualifica queste liste come tali. - findAll(tipoEventoId?: string): Promise { - return prisma.listaModello.findMany({ - where: { pubblica: true, ...(tipoEventoId ? { tipoEventoId } : {}) }, - include: includeVoci, - orderBy: { nome: 'asc' }, - }); - } - - findById(id: string): Promise { - return prisma.listaModello.findUnique({ where: { id }, include: includeVoci }); - } - - create(data: CreateListaModelloData): Promise { - return prisma.listaModello.create({ - data: { - nome: data.nome, - tipoEventoId: data.tipoEventoId, - pubblica: true, - voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) }, - }, - include: includeVoci, - }); - } - - update(id: string, data: UpdateListaModelloData): Promise { - return prisma.$transaction(async (tx) => { - if (data.voci) { - await tx.listaModelloVoce.deleteMany({ where: { listaModelloId: id } }); - } - - return tx.listaModello.update({ - where: { id }, - data: { - ...(data.nome !== undefined ? { nome: data.nome } : {}), - ...(data.tipoEventoId !== undefined ? { tipoEventoId: data.tipoEventoId } : {}), - ...(data.voci - ? { voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) } } - : {}), - }, - include: includeVoci, - }); - }); - } - - async delete(id: string): Promise { - await prisma.$transaction(async (tx) => { - await tx.listaModelloVoce.deleteMany({ where: { listaModelloId: id } }); - await tx.listaModello.delete({ where: { id } }); - }); - } -} - -export const listeModelloRepository = new ListeModelloRepository(); diff --git a/scouthub-magazzino-be/src/repositories/magazzino.repository.ts b/scouthub-magazzino-be/src/repositories/magazzino.repository.ts index 792c88d..e6c482b 100644 --- a/scouthub-magazzino-be/src/repositories/magazzino.repository.ts +++ b/scouthub-magazzino-be/src/repositories/magazzino.repository.ts @@ -14,6 +14,7 @@ export interface CreateMagazzinoVoceData { stato: StatoMagazzinoVoce; posizione?: string; note?: string; + gruppoId?: string | null; } export interface UpdateMagazzinoVoceData { @@ -22,6 +23,7 @@ export interface UpdateMagazzinoVoceData { stato?: StatoMagazzinoVoce; posizione?: string | null; note?: string | null; + gruppoId?: string | null; } export interface QuantitaPosseduta { diff --git a/scouthub-magazzino-be/src/repositories/materiali.repository.ts b/scouthub-magazzino-be/src/repositories/materiali.repository.ts index 79d0c4f..e7bb8a2 100644 --- a/scouthub-magazzino-be/src/repositories/materiali.repository.ts +++ b/scouthub-magazzino-be/src/repositories/materiali.repository.ts @@ -6,12 +6,23 @@ export interface CreateMaterialeData { categoria: string; unitaMisura: string; propostoDaOrgId: string; + stato?: StatoMateriale; +} + +export interface UpdateMaterialeData { + nome: string; + categoria: string; + unitaMisura: string; } export class MaterialiRepository { - findApprovati(categoria?: string): Promise { + findApprovati(categoria?: string, nome?: string): Promise { return prisma.materiale.findMany({ - where: { stato: StatoMateriale.approvato, ...(categoria ? { categoria } : {}) }, + where: { + stato: StatoMateriale.approvato, + ...(categoria ? { categoria } : {}), + ...(nome ? { nome: { contains: nome, mode: 'insensitive' } } : {}), + }, orderBy: { nome: 'asc' }, }); } @@ -29,13 +40,21 @@ export class MaterialiRepository { create(data: CreateMaterialeData): Promise { return prisma.materiale.create({ - data: { ...data, stato: StatoMateriale.proposto }, + data: { ...data, stato: data.stato ?? StatoMateriale.proposto }, }); } + update(id: string, data: UpdateMaterialeData): Promise { + return prisma.materiale.update({ where: { id }, data }); + } + updateStato(id: string, stato: StatoMateriale): Promise { return prisma.materiale.update({ where: { id }, data: { stato } }); } + + async delete(id: string): Promise { + await prisma.materiale.delete({ where: { id } }); + } } export const materialiRepository = new MaterialiRepository(); diff --git a/scouthub-magazzino-be/src/repositories/notifiche.repository.ts b/scouthub-magazzino-be/src/repositories/notifiche.repository.ts new file mode 100644 index 0000000..97a26fd --- /dev/null +++ b/scouthub-magazzino-be/src/repositories/notifiche.repository.ts @@ -0,0 +1,36 @@ +import { Notifica, TipoNotifica } from '@prisma/client'; +import { prisma } from '../db/prisma'; + +export interface CreateNotificaData { + tipo: TipoNotifica; + messaggio: string; + link?: string; +} + +export class NotificheRepository { + findAll(): Promise { + return prisma.notifica.findMany({ orderBy: { dataCreazione: 'desc' }, take: 50 }); + } + + countNonLette(): Promise { + return prisma.notifica.count({ where: { letta: false } }); + } + + findById(id: number): Promise { + return prisma.notifica.findUnique({ where: { id } }); + } + + segnaLetta(id: number): Promise { + return prisma.notifica.update({ where: { id }, data: { letta: true } }); + } + + async segnaTutteLette(): Promise { + await prisma.notifica.updateMany({ where: { letta: false }, data: { letta: true } }); + } + + create(data: CreateNotificaData): Promise { + return prisma.notifica.create({ data }); + } +} + +export const notificheRepository = new NotificheRepository(); diff --git a/scouthub-magazzino-be/src/repositories/tipiEvento.repository.ts b/scouthub-magazzino-be/src/repositories/tipiEvento.repository.ts index fb3c341..25fa30f 100644 --- a/scouthub-magazzino-be/src/repositories/tipiEvento.repository.ts +++ b/scouthub-magazzino-be/src/repositories/tipiEvento.repository.ts @@ -1,19 +1,45 @@ -import { TipoEvento } from '@prisma/client'; +import { StatoCategoria, TipoEvento } from '@prisma/client'; import { prisma } from '../db/prisma'; +export interface CreateTipoEventoData { + nome: string; + stato: StatoCategoria; + creatoDaOrgId?: string | null; +} + export class TipiEventoRepository { + // Pubblico (catalogo, autocomplete): solo i tipi evento confermati. + findConfermati(nome?: string): Promise { + return prisma.tipoEvento.findMany({ + where: { + stato: StatoCategoria.confermata, + ...(nome ? { nome: { contains: nome, mode: 'insensitive' } } : {}), + }, + orderBy: { nome: 'asc' }, + }); + } + + // Moderazione: tutti gli stati, incluse le proposte in attesa. findAll(): Promise { return prisma.tipoEvento.findMany({ orderBy: { nome: 'asc' } }); } - create(nome: string): Promise { - return prisma.tipoEvento.create({ data: { nome } }); + findById(id: string): Promise { + return prisma.tipoEvento.findUnique({ where: { id } }); + } + + create(data: CreateTipoEventoData): Promise { + return prisma.tipoEvento.create({ data }); } update(id: string, nome: string): Promise { return prisma.tipoEvento.update({ where: { id }, data: { nome } }); } + updateStato(id: string, stato: StatoCategoria): Promise { + return prisma.tipoEvento.update({ where: { id }, data: { stato } }); + } + async delete(id: string): Promise { await prisma.tipoEvento.delete({ where: { id } }); } diff --git a/scouthub-magazzino-be/src/routes/autocomplete.routes.ts b/scouthub-magazzino-be/src/routes/autocomplete.routes.ts new file mode 100644 index 0000000..cd9026a --- /dev/null +++ b/scouthub-magazzino-be/src/routes/autocomplete.routes.ts @@ -0,0 +1,6 @@ +import { Router } from 'express'; +import { postSearch } from '../controllers/autocomplete.controller'; + +export const autocompleteRouter = Router(); + +autocompleteRouter.post('/autocomplete/search', postSearch); diff --git a/scouthub-magazzino-be/src/routes/categorie.routes.ts b/scouthub-magazzino-be/src/routes/categorie.routes.ts new file mode 100644 index 0000000..e8deb62 --- /dev/null +++ b/scouthub-magazzino-be/src/routes/categorie.routes.ts @@ -0,0 +1,25 @@ +import { Router } from 'express'; +import { verifyToken } from '../auth/verify-token.middleware'; +import { requireOrgId } from '../auth/require-org-id.middleware'; +import { requireModeratore } from '../auth/require-moderatore.middleware'; +import { + deleteCategoria, + getCategorie, + getCategoriePubbliche, + postCategoria, + postCategoriaApprova, + postCategoriaProposta, + postCategoriaRifiuta, + putCategoria, +} from '../controllers/categorie.controller'; + +export const categorieRouter = Router(); + +categorieRouter.get('/categorie/pubbliche', getCategoriePubbliche); +categorieRouter.get('/categorie', verifyToken, requireModeratore, getCategorie); +categorieRouter.post('/categorie', verifyToken, requireModeratore, postCategoria); +categorieRouter.put('/categorie/:id', verifyToken, requireModeratore, putCategoria); +categorieRouter.delete('/categorie/:id', verifyToken, requireModeratore, deleteCategoria); +categorieRouter.post('/categorie/:id/approva', verifyToken, requireModeratore, postCategoriaApprova); +categorieRouter.post('/categorie/:id/rifiuta', verifyToken, requireModeratore, postCategoriaRifiuta); +categorieRouter.post('/categorie/proposte', verifyToken, requireOrgId, postCategoriaProposta); diff --git a/scouthub-magazzino-be/src/routes/gruppiMagazzino.routes.ts b/scouthub-magazzino-be/src/routes/gruppiMagazzino.routes.ts new file mode 100644 index 0000000..bf32c99 --- /dev/null +++ b/scouthub-magazzino-be/src/routes/gruppiMagazzino.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { verifyToken } from '../auth/verify-token.middleware'; +import { requireOrgId } from '../auth/require-org-id.middleware'; +import { + deleteGruppoMagazzino, + getGruppiMagazzino, + postGruppoMagazzino, + putGruppoMagazzino, +} from '../controllers/gruppiMagazzino.controller'; + +export const gruppiMagazzinoRouter = Router(); + +gruppiMagazzinoRouter.get('/gruppi-magazzino', verifyToken, requireOrgId, getGruppiMagazzino); +gruppiMagazzinoRouter.post('/gruppi-magazzino', verifyToken, requireOrgId, postGruppoMagazzino); +gruppiMagazzinoRouter.put('/gruppi-magazzino/:id', verifyToken, requireOrgId, putGruppoMagazzino); +gruppiMagazzinoRouter.delete('/gruppi-magazzino/:id', verifyToken, requireOrgId, deleteGruppoMagazzino); diff --git a/scouthub-magazzino-be/src/routes/liste.routes.ts b/scouthub-magazzino-be/src/routes/liste.routes.ts index d455edf..279f7c2 100644 --- a/scouthub-magazzino-be/src/routes/liste.routes.ts +++ b/scouthub-magazzino-be/src/routes/liste.routes.ts @@ -1,12 +1,29 @@ import { Router } from 'express'; import { verifyToken } from '../auth/verify-token.middleware'; -import { requireOrgId } from '../auth/require-org-id.middleware'; -import { deleteLista, getListe, postLista, postListaDaModello, putLista } from '../controllers/liste.controller'; +import { requireModeratore } from '../auth/require-moderatore.middleware'; +import { + deleteLista, + getListaPubblicaById, + getListe, + getListePubbliche, + getListeProposte, + patchListaProposta, + postLista, + postListaDaFork, + putLista, +} from '../controllers/liste.controller'; export const listeRouter = Router(); -listeRouter.get('/liste', verifyToken, requireOrgId, getListe); -listeRouter.post('/liste', verifyToken, requireOrgId, postLista); -listeRouter.post('/liste/da-modello/:listaModelloId', verifyToken, requireOrgId, postListaDaModello); -listeRouter.put('/liste/:id', verifyToken, requireOrgId, putLista); -listeRouter.delete('/liste/:id', verifyToken, requireOrgId, deleteLista); +listeRouter.get('/liste', verifyToken, getListe); +// Nessun middleware: le liste 'pubblico' approvate sono visibili anche senza +// autenticazione. Le due route sotto vanno dichiarate prima di eventuali futuri +// GET /liste/:id generici, per evitare ambiguità di matching. +listeRouter.get('/liste/pubbliche', getListePubbliche); +listeRouter.get('/liste/pubbliche/:id', getListaPubblicaById); +listeRouter.get('/liste/proposte', verifyToken, requireModeratore, getListeProposte); +listeRouter.patch('/liste/proposte/:id', verifyToken, requireModeratore, patchListaProposta); +listeRouter.post('/liste', verifyToken, postLista); +listeRouter.post('/liste/da-fork/:id', verifyToken, postListaDaFork); +listeRouter.put('/liste/:id', verifyToken, putLista); +listeRouter.delete('/liste/:id', verifyToken, deleteLista); diff --git a/scouthub-magazzino-be/src/routes/listeModello.routes.ts b/scouthub-magazzino-be/src/routes/listeModello.routes.ts deleted file mode 100644 index f5cd0f1..0000000 --- a/scouthub-magazzino-be/src/routes/listeModello.routes.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router } from 'express'; -import { verifyToken } from '../auth/verify-token.middleware'; -import { requireModeratore } from '../auth/require-moderatore.middleware'; -import { - deleteListaModello, - getListeModello, - postListaModello, - putListaModello, -} from '../controllers/listeModello.controller'; - -export const listeModelloRouter = Router(); - -listeModelloRouter.get('/liste-modello', getListeModello); -listeModelloRouter.post('/liste-modello', verifyToken, requireModeratore, postListaModello); -listeModelloRouter.put('/liste-modello/:id', verifyToken, requireModeratore, putListaModello); -listeModelloRouter.delete('/liste-modello/:id', verifyToken, requireModeratore, deleteListaModello); diff --git a/scouthub-magazzino-be/src/routes/materiali.routes.ts b/scouthub-magazzino-be/src/routes/materiali.routes.ts index d1bfb09..e1cc1ee 100644 --- a/scouthub-magazzino-be/src/routes/materiali.routes.ts +++ b/scouthub-magazzino-be/src/routes/materiali.routes.ts @@ -2,11 +2,22 @@ import { Router } from 'express'; import { verifyToken } from '../auth/verify-token.middleware'; import { requireOrgId } from '../auth/require-org-id.middleware'; import { requireModeratore } from '../auth/require-moderatore.middleware'; -import { getMaterialiPubblici, getProposte, patchProposta, postProposta } from '../controllers/materiali.controller'; +import { + deleteMateriale, + getMaterialiPubblici, + getProposte, + patchProposta, + postMateriale, + postProposta, + putMateriale, +} from '../controllers/materiali.controller'; export const materialiRouter = Router(); materialiRouter.get('/materiali', getMaterialiPubblici); +materialiRouter.post('/materiali', verifyToken, requireOrgId, requireModeratore, postMateriale); +materialiRouter.put('/materiali/:id', verifyToken, requireModeratore, putMateriale); +materialiRouter.delete('/materiali/:id', verifyToken, requireModeratore, deleteMateriale); materialiRouter.post('/materiali/proposte', verifyToken, requireOrgId, postProposta); materialiRouter.get('/materiali/proposte', verifyToken, requireModeratore, getProposte); materialiRouter.patch('/materiali/proposte/:id', verifyToken, requireModeratore, patchProposta); diff --git a/scouthub-magazzino-be/src/routes/notifiche.routes.ts b/scouthub-magazzino-be/src/routes/notifiche.routes.ts new file mode 100644 index 0000000..ce77724 --- /dev/null +++ b/scouthub-magazzino-be/src/routes/notifiche.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { verifyToken } from '../auth/verify-token.middleware'; +import { requireModeratore } from '../auth/require-moderatore.middleware'; +import { getCountNonLette, getNotifiche, putLetta, putLetteTutte } from '../controllers/notifiche.controller'; + +export const notificheRouter = Router(); + +notificheRouter.get('/notifiche', verifyToken, requireModeratore, getNotifiche); +notificheRouter.get('/notifiche/non-lette/count', verifyToken, requireModeratore, getCountNonLette); +notificheRouter.put('/notifiche/letta-tutte', verifyToken, requireModeratore, putLetteTutte); +notificheRouter.put('/notifiche/:id/letta', verifyToken, requireModeratore, putLetta); diff --git a/scouthub-magazzino-be/src/routes/tipiEvento.routes.ts b/scouthub-magazzino-be/src/routes/tipiEvento.routes.ts index 2ed3907..3f66365 100644 --- a/scouthub-magazzino-be/src/routes/tipiEvento.routes.ts +++ b/scouthub-magazzino-be/src/routes/tipiEvento.routes.ts @@ -1,11 +1,25 @@ import { Router } from 'express'; import { verifyToken } from '../auth/verify-token.middleware'; +import { requireOrgId } from '../auth/require-org-id.middleware'; import { requireModeratore } from '../auth/require-moderatore.middleware'; -import { deleteTipoEvento, getTipiEvento, postTipoEvento, putTipoEvento } from '../controllers/tipiEvento.controller'; +import { + deleteTipoEvento, + getTipiEvento, + getTipiEventoModerazione, + postTipoEvento, + postTipoEventoApprova, + postTipoEventoProposta, + postTipoEventoRifiuta, + putTipoEvento, +} from '../controllers/tipiEvento.controller'; export const tipiEventoRouter = Router(); tipiEventoRouter.get('/tipi-evento', getTipiEvento); +tipiEventoRouter.get('/tipi-evento/moderazione', verifyToken, requireModeratore, getTipiEventoModerazione); tipiEventoRouter.post('/tipi-evento', verifyToken, requireModeratore, postTipoEvento); +tipiEventoRouter.post('/tipi-evento/proposte', verifyToken, requireOrgId, postTipoEventoProposta); tipiEventoRouter.put('/tipi-evento/:id', verifyToken, requireModeratore, putTipoEvento); tipiEventoRouter.delete('/tipi-evento/:id', verifyToken, requireModeratore, deleteTipoEvento); +tipiEventoRouter.post('/tipi-evento/:id/approva', verifyToken, requireModeratore, postTipoEventoApprova); +tipiEventoRouter.post('/tipi-evento/:id/rifiuta', verifyToken, requireModeratore, postTipoEventoRifiuta); diff --git a/scouthub-magazzino-be/src/services/autocomplete.service.ts b/scouthub-magazzino-be/src/services/autocomplete.service.ts new file mode 100644 index 0000000..3001ea8 --- /dev/null +++ b/scouthub-magazzino-be/src/services/autocomplete.service.ts @@ -0,0 +1,36 @@ +import { listTipiEventoConfermati } from './tipiEvento.service'; +import { listMaterialiApprovati } from './materiali.service'; + +export interface AutocompleteObjectDto { + id: string; + nome: string; + gruppo: 'tipoEvento' | 'materiale'; +} + +export interface AutocompleteGroupDto { + label: string; + gruppo: 'tipoEvento' | 'materiale'; + objectsList: AutocompleteObjectDto[]; +} + +export async function search(keyword?: string): Promise { + const [tipiEvento, materiali] = await Promise.all([ + listTipiEventoConfermati(keyword), + listMaterialiApprovati(undefined, keyword), + ]); + + const gruppi: AutocompleteGroupDto[] = [ + { + label: 'Tipo evento', + gruppo: 'tipoEvento', + objectsList: tipiEvento.map((tipoEvento) => ({ id: tipoEvento.id, nome: tipoEvento.nome, gruppo: 'tipoEvento' })), + }, + { + label: 'Materiale', + gruppo: 'materiale', + objectsList: materiali.map((materiale) => ({ id: materiale.id, nome: materiale.nome, gruppo: 'materiale' })), + }, + ]; + + return gruppi.filter((gruppo) => gruppo.objectsList.length > 0); +} diff --git a/scouthub-magazzino-be/src/services/categorie.service.ts b/scouthub-magazzino-be/src/services/categorie.service.ts new file mode 100644 index 0000000..c638f1c --- /dev/null +++ b/scouthub-magazzino-be/src/services/categorie.service.ts @@ -0,0 +1,76 @@ +import { Categoria, StatoCategoria } from '@prisma/client'; +import { categorieRepository } from '../repositories/categorie.repository'; +import { HttpError } from '../errors'; +import { toHttpError } from '../utils/prisma-errors'; +import { creaNotificaModerazione } from './notifiche.service'; + +export function listCategorie(): Promise { + return categorieRepository.findAll(); +} + +export function listCategorieConfermate(nome?: string): Promise { + return categorieRepository.findConfermate(nome); +} + +export function creaCategoria(nome: string): Promise { + return categorieRepository.create({ nome, stato: StatoCategoria.confermata }); +} + +export async function aggiornaCategoria(id: string, nome: string): Promise { + try { + return await categorieRepository.update(id, nome); + } catch (err) { + throw toHttpError(err, 'Categoria non trovata', "Conflitto durante l'aggiornamento della categoria"); + } +} + +export async function eliminaCategoria(id: string): Promise { + try { + await categorieRepository.delete(id); + } catch (err) { + throw toHttpError(err, 'Categoria non trovata', 'Impossibile eliminare la categoria: è referenziata altrove'); + } +} + +export interface ProponiCategoriaInput { + nome: string; + orgId: string; +} + +export async function proponiCategoria(input: ProponiCategoriaInput): Promise { + const categoria = await categorieRepository.create({ + nome: input.nome, + stato: StatoCategoria.da_approvare, + creatoDaOrgId: input.orgId, + }); + await creaNotificaModerazione( + 'CATEGORIA_PROPOSTA', + `Nuova categoria proposta: "${categoria.nome}"`, + '/tassonomie?tab=categorie', + ); + return categoria; +} + +export async function approvaCategoria(id: string): Promise { + const categoria = await categorieRepository.findById(id); + if (!categoria) { + throw new HttpError(404, 'Categoria non trovata'); + } + if (categoria.stato !== StatoCategoria.da_approvare) { + throw new HttpError(409, 'La categoria è già confermata'); + } + + return categorieRepository.updateStato(id, StatoCategoria.confermata); +} + +export async function rifiutaCategoria(id: string): Promise { + const categoria = await categorieRepository.findById(id); + if (!categoria) { + throw new HttpError(404, 'Categoria non trovata'); + } + if (categoria.stato !== StatoCategoria.da_approvare) { + throw new HttpError(409, 'Solo le proposte in attesa possono essere rifiutate'); + } + + await categorieRepository.delete(id); +} diff --git a/scouthub-magazzino-be/src/services/gruppiMagazzino.service.ts b/scouthub-magazzino-be/src/services/gruppiMagazzino.service.ts new file mode 100644 index 0000000..e9d2feb --- /dev/null +++ b/scouthub-magazzino-be/src/services/gruppiMagazzino.service.ts @@ -0,0 +1,115 @@ +import { + CreateGruppoMagazzinoData, + GruppoMagazzinoConAlbero, + UpdateGruppoMagazzinoData, + gruppiMagazzinoRepository, +} from '../repositories/gruppiMagazzino.repository'; +import { HttpError } from '../errors'; +import { toHttpError } from '../utils/prisma-errors'; +import { MagazzinoVoceView, toView as toVoceView } from './magazzino.service'; + +export interface GruppoMagazzinoView { + id: string; + orgId: string; + nome: string; + parentId: string | null; + creatoIl: Date; + voci: MagazzinoVoceView[]; + // Sempre vuoto sui gruppi restituiti come figli: un solo livello di nesting. + figli: GruppoMagazzinoView[]; +} + +function toView(gruppo: GruppoMagazzinoConAlbero): GruppoMagazzinoView { + return { + id: gruppo.id, + orgId: gruppo.orgId, + nome: gruppo.nome, + parentId: gruppo.parentId, + creatoIl: gruppo.creatoIl, + voci: gruppo.voci.map(toVoceView), + figli: gruppo.figli.map((figlio) => ({ + id: figlio.id, + orgId: figlio.orgId, + nome: figlio.nome, + parentId: figlio.parentId, + creatoIl: figlio.creatoIl, + voci: figlio.voci.map(toVoceView), + figli: [], + })), + }; +} + +// Solo l'albero radice: i figli arrivano annidati (vedi toView), niente +// duplicazione di un gruppo figlio come voce di primo livello nella risposta. +export function listGruppiPerOrg(orgId: string): Promise { + return gruppiMagazzinoRepository.findRadiceByOrg(orgId).then((gruppi) => gruppi.map(toView)); +} + +async function assicuraGenitoreValido(parentId: string, orgId: string): Promise { + const genitore = await gruppiMagazzinoRepository.findRadiceByIdAndOrg(parentId, orgId); + if (!genitore) { + throw new HttpError( + 400, + 'Il gruppo padre indicato non esiste, non appartiene alla tua organizzazione o non è un gruppo di primo livello', + ); + } +} + +export interface CreaGruppoInput { + nome: string; + parentId?: string | null; +} + +export async function creaGruppo(orgId: string, input: CreaGruppoInput): Promise { + if (input.parentId) { + await assicuraGenitoreValido(input.parentId, orgId); + } + + const data: CreateGruppoMagazzinoData = { orgId, nome: input.nome, parentId: input.parentId ?? null }; + const gruppo = await gruppiMagazzinoRepository.create(data); + return toView(gruppo); +} + +async function trovaGruppoDiOrg(id: string, orgId: string): Promise { + const gruppo = await gruppiMagazzinoRepository.findByIdAndOrg(id, orgId); + if (!gruppo) { + throw new HttpError(404, 'Gruppo di magazzino non trovato'); + } + return gruppo; +} + +export interface AggiornaGruppoInput { + nome?: string; + parentId?: string | null; +} + +export async function aggiornaGruppo(id: string, orgId: string, input: AggiornaGruppoInput): Promise { + const esistente = await trovaGruppoDiOrg(id, orgId); + + if (input.parentId !== undefined && input.parentId !== null) { + if (input.parentId === id) { + throw new HttpError(400, 'Un gruppo non può essere padre di se stesso'); + } + if (esistente.figli.length > 0) { + throw new HttpError(400, 'Un gruppo con dei figli non può a sua volta diventare figlio di un altro gruppo'); + } + await assicuraGenitoreValido(input.parentId, orgId); + } + + const data: UpdateGruppoMagazzinoData = { nome: input.nome, parentId: input.parentId }; + try { + const gruppo = await gruppiMagazzinoRepository.update(id, data); + return toView(gruppo); + } catch (err) { + throw toHttpError(err, 'Gruppo di magazzino non trovato', 'Il gruppo padre indicato non esiste'); + } +} + +export async function eliminaGruppo(id: string, orgId: string): Promise { + await trovaGruppoDiOrg(id, orgId); + try { + await gruppiMagazzinoRepository.delete(id); + } catch (err) { + throw toHttpError(err, 'Gruppo di magazzino non trovato', 'Impossibile eliminare il gruppo di magazzino'); + } +} diff --git a/scouthub-magazzino-be/src/services/liste.service.ts b/scouthub-magazzino-be/src/services/liste.service.ts index e9bbeb5..080ea5e 100644 --- a/scouthub-magazzino-be/src/services/liste.service.ts +++ b/scouthub-magazzino-be/src/services/liste.service.ts @@ -1,89 +1,423 @@ +import { Prisma, PrismaClient, StatoLista, StatoModerazioneLista } from '@prisma/client'; +import { prisma } from '../db/prisma'; import { ListaConVoci, ListaVoceInput, listeRepository } from '../repositories/liste.repository'; -import { listeModelloRepository } from '../repositories/listeModello.repository'; import { HttpError } from '../errors'; import { toHttpError } from '../utils/prisma-errors'; +import { creaNotificaModerazione } from './notifiche.service'; export interface ListaVoceView { materialeId: string; nome: string; unitaMisura: string; quantita: number; + // Presente solo se il chiamante può vederlo (vedi toView): il materiale è stato + // proposto da poco e non è ancora stato approvato dal moderatore. + inAttesaConferma?: boolean; +} + +export interface SottoListaView { + id: string; + nome: string; + voci: ListaVoceView[]; } export interface ListaView { id: string; nome: string; - orgId: string; + orgId: string | null; + stato: StatoLista; + // Nullo per bozza/privato/gruppo: la moderazione si applica solo quando stato = + // pubblico (vedi risolviStatoModerazione). + statoModerazione: StatoModerazioneLista | null; + tipoEventoId: string | null; + // Da quale lista pubblica approvata è stata forkata questa (fork = "usa come base"). + parentId: string | null; creataIl: Date; + // Il chiamante è l'autore della lista: per le liste 'gruppo' (visibili a tutta + // l'org, non solo a chi le ha create) serve al frontend per distinguere "le mie + // liste di gruppo" dalle liste di gruppo altrui nella stessa vista. + creataDaMe: boolean; voci: ListaVoceView[]; + sottoListe: SottoListaView[]; } -function toView(lista: ListaConVoci): ListaView { +// Chi sta leggendo/scrivendo la lista. orgId è l'organizzazione attiva sul token (null +// se l'utente non ne ha una: le liste personali bozza/privato/pubblico non richiedono +// un'org). Serve sia per decidere se mostrare inAttesaConferma sia per l'ownership. +export interface Viewer { + userId: string; + orgId: string | null; + roles: string[]; +} + +const viewerAnonimo: Viewer = { userId: '', orgId: null, roles: [] }; +// Usato solo per costruire la ListaView di ritorno delle route di moderazione (già +// protette da requireModeratore a livello router): garantisce che inAttesaConferma sui +// materiali dentro la lista sia visibile a chi la sta decidendo. +const viewerModeratore: Viewer = { userId: '', orgId: null, roles: ['moderatore'] }; + +function toVoceView(v: ListaConVoci['voci'][number], puoVedereAttesaConferma: boolean): ListaVoceView { + return { + materialeId: v.materialeId, + nome: v.materiale.nome, + unitaMisura: v.materiale.unitaMisura, + quantita: v.quantita, + ...(puoVedereAttesaConferma && v.materiale.stato === 'proposto' ? { inAttesaConferma: true } : {}), + }; +} + +function toView(lista: ListaConVoci, viewer: Viewer): ListaView { + const puoVedereAttesaConferma = lista.creataDaUserId === viewer.userId || viewer.roles.includes('moderatore'); + return { id: lista.id, nome: lista.nome, orgId: lista.orgId, + stato: lista.stato, + statoModerazione: lista.statoModerazione, + tipoEventoId: lista.tipoEventoId, + parentId: lista.parentId, creataIl: lista.creataIl, - voci: lista.voci.map((v) => ({ - materialeId: v.materialeId, - nome: v.materiale.nome, - unitaMisura: v.materiale.unitaMisura, - quantita: v.quantita, + creataDaMe: lista.creataDaUserId === viewer.userId, + voci: lista.voci.map((v) => toVoceView(v, puoVedereAttesaConferma)), + sottoListe: lista.sottoListe.map((sl) => ({ + id: sl.sottoLista.id, + nome: sl.sottoLista.nome, + voci: sl.sottoLista.voci.map((v) => toVoceView(v, puoVedereAttesaConferma)), })), }; } -export async function listListePerOrg(orgId: string): Promise { - const liste = await listeRepository.findAllByOrg(orgId); - return liste.map(toView); +// "Le mie liste": le proprie (qualunque stato) + le liste di gruppo della propria org, +// create da chiunque nell'org (comportamento collaborativo). +export async function listMieListe(viewer: Viewer): Promise { + const liste = await listeRepository.findVisibiliPerUtente(viewer.userId, viewer.orgId); + return liste.map((lista) => toView(lista, viewer)); } -export async function creaListaVuota(orgId: string, nome: string): Promise { - const lista = await listeRepository.create({ nome, orgId, voci: [] }); - return toView(lista); +// Catalogo pubblico: liste 'pubblico' già approvate dalla moderazione, visibili anche +// senza autenticazione. Nessun inAttesaConferma per un chiamante anonimo. +export async function listListePubbliche(tipoEventoId?: string): Promise { + const liste = await listeRepository.findApprovatePubbliche(tipoEventoId); + return liste.map((lista) => toView(lista, viewerAnonimo)); } -// Fork: copia nome e voci correnti della lista modello in una nuova lista -// dell'org. Da qui in poi le due entità non hanno più alcun legame: nessun -// listaModelloId viene salvato sulla lista creata. -export async function forkListaDaModello(orgId: string, listaModelloId: string): Promise { - const modello = await listeModelloRepository.findById(listaModelloId); - if (!modello || !modello.pubblica) { - throw new HttpError(404, 'Lista modello non trovata'); - } - - const voci: ListaVoceInput[] = modello.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })); - const lista = await listeRepository.create({ nome: modello.nome, orgId, voci }); - return toView(lista); -} - -async function assicuraListaDiOrg(id: string, orgId: string): Promise { - const lista = await listeRepository.findByIdAndOrg(id, orgId); +// Dettaglio pubblico per singolo id (deep-link diretto al catalogo, a parità con il +// vecchio GET /liste-modello/:id). +export async function trovaListaPubblicaPerId(id: string): Promise { + const lista = await listeRepository.findApprovataPerId(id); if (!lista) { throw new HttpError(404, 'Lista non trovata'); } + return toView(lista, viewerAnonimo); +} + +// Coda di moderazione: liste pubbliche in attesa di decisione. +export async function listProposte(): Promise { + const liste = await listeRepository.findProposte(); + return liste.map((lista) => toView(lista, viewerModeratore)); +} + +export type DecisioneProposta = 'approvato' | 'rifiutato'; + +// Mirror esatto di materiali.service.ts::decidiProposta: transizione singola non +// reversibile via questo endpoint. +export async function decidiProposta(id: string, decisione: DecisioneProposta): Promise { + const lista = await listeRepository.findById(id); + if (!lista) { + throw new HttpError(404, 'Proposta non trovata'); + } + if (lista.statoModerazione !== 'proposto') { + throw new HttpError(409, 'La proposta è già stata decisa'); + } + + const statoModerazione: StatoModerazioneLista = decisione === 'approvato' ? 'approvato' : 'rifiutato'; + const aggiornata = await listeRepository.updateStatoModerazione(id, statoModerazione); + return toView(aggiornata, viewerModeratore); +} + +// Solo le liste 'gruppo' hanno un'org: per tutti gli altri stati l'org del token viene +// sempre ignorata, anche se presente. +function risolviOrgId(stato: StatoLista, viewerOrgId: string | null): string | null { + if (stato !== 'gruppo') { + return null; + } + if (!viewerOrgId) { + throw new HttpError(400, "Serve un'organizzazione attiva per creare una lista di gruppo"); + } + return viewerOrgId; +} + +// Ogni volta che una lista entra in stato 'pubblico', il backend forza +// statoModerazione='proposto' (ignora qualunque input client) — TRANNE quando il +// richiedente ha ruolo 'moderatore', che può impostarla direttamente 'approvato' +// (scrittura diretta, caso raro, mirror del vecchio ListaModello.pubblica sempre +// forzato a true per chi passa già da requireModeratore). +function risolviStatoModerazione(stato: StatoLista, viewer: Viewer): StatoModerazioneLista | null { + if (stato !== 'pubblico') { + return null; + } + return viewer.roles.includes('moderatore') ? 'approvato' : 'proposto'; +} + +// In update, la regola sopra si applica solo quando la lista entra ora in 'pubblico' o +// quando il suo contenuto (voci/sotto-liste) viene modificato mentre è già pubblico: un +// update banale (es. solo il nome) su una lista già pubblica non deve resettare una +// moderazione già decisa. +function risolviStatoModerazioneUpdate( + statoFinale: StatoLista, + statoCambiato: boolean, + contenutoModificato: boolean, + statoModerazioneAttuale: StatoModerazioneLista | null, + viewer: Viewer, +): StatoModerazioneLista | null { + if (statoFinale !== 'pubblico') { + return null; + } + if (statoCambiato || contenutoModificato) { + return risolviStatoModerazione(statoFinale, viewer); + } + return statoModerazioneAttuale; +} + +// Una lista può essere allegata come sotto-lista di un'altra solo se è "foglia" (nessuna +// auto-referenza, deve esistere ed essere visibile al viewer secondo le regole di +// ambito, non deve avere a sua volta sotto-liste — un solo livello di nesting, niente +// cicli). Una lista personale può agganciare solo proprie liste personali + liste +// pubbliche approvate; una lista di gruppo può agganciare anche liste di gruppo della +// stessa org. +async function validaSottoListe( + tx: Prisma.TransactionClient, + viewer: Viewer, + listaId: string | undefined, + statoListaCorrente: StatoLista, + sottoListeIds: string[], +): Promise { + if (sottoListeIds.length === 0) { + return; + } + if (listaId && sottoListeIds.includes(listaId)) { + throw new HttpError(400, 'Una lista non può contenere se stessa come sotto-lista'); + } + + const permettiGruppo = statoListaCorrente === 'gruppo'; + const visibili = await listeRepository.trovaVisibiliComeSottoLista( + viewer.userId, + viewer.orgId, + permettiGruppo, + sottoListeIds, + tx, + ); + if (visibili.length !== new Set(sottoListeIds).size) { + throw new HttpError(400, 'Una delle sotto-liste indicate non esiste o non è utilizzabile'); + } + + const conSottoListe = await listeRepository.trovaConSottoListe(sottoListeIds, tx); + if (conSottoListe.length > 0) { + throw new HttpError(400, 'Una sotto-lista non può contenere a sua volta altre sotto-liste'); + } +} + +// I materiali di una lista pubblica usata come base (voci di primo livello + voci delle +// sue sotto-liste, che per vincolo applicativo sono al massimo un livello) vengono +// appiattiti in un unico elenco, sommando le quantità se lo stesso materiale compare +// più volte. +function flattenInVoci(lista: ListaConVoci): ListaVoceInput[] { + const quantitaPerMateriale = new Map(); + for (const v of lista.voci) { + quantitaPerMateriale.set(v.materialeId, (quantitaPerMateriale.get(v.materialeId) ?? 0) + v.quantita); + } + for (const sottoLista of lista.sottoListe) { + for (const v of sottoLista.sottoLista.voci) { + quantitaPerMateriale.set(v.materialeId, (quantitaPerMateriale.get(v.materialeId) ?? 0) + v.quantita); + } + } + return Array.from(quantitaPerMateriale, ([materialeId, quantita]) => ({ materialeId, quantita })); +} + +// Fork: copia nome/voci/tipoEventoId correnti di una lista pubblica approvata in una +// nuova lista personale bozza, impostando parentId per tracciare la provenienza. Il +// contenuto è sempre "appiattito" dalle eventuali sotto-liste di primo livello della +// lista di origine, perché la lista figlia nasce senza sotto-liste proprie. +async function forkLista( + db: Prisma.TransactionClient | PrismaClient, + sorgenteId: string, + viewer: Viewer, +): Promise { + const sorgente = await listeRepository.findApprovataPerId(sorgenteId, db); + if (!sorgente) { + throw new HttpError(404, 'Lista non trovata'); + } + return listeRepository.create( + { + nome: sorgente.nome, + orgId: null, + creataDaUserId: viewer.userId, + stato: 'bozza', + statoModerazione: null, + tipoEventoId: sorgente.tipoEventoId, + parentId: sorgente.id, + voci: flattenInVoci(sorgente), + sottoListeIds: [], + }, + db, + ); +} + +// Endpoint pubblico dietro POST /liste/da-fork/:id — sostituisce forkListaDaModello. +export async function forkListaDaLista(listaOrigineId: string, viewer: Viewer): Promise { + const lista = await forkLista(prisma, listaOrigineId, viewer); + return toView(lista, viewer); +} + +export interface SottoListeInput { + // Liste personali già esistenti, visibili al viewer secondo le regole di ambito. + sottoListeIds?: string[]; + // Liste pubbliche approvate del catalogo: vengono forkate al volo (stesso + // flattening di forkLista) in nuove liste bozza personali (mai di gruppo, a + // prescindere dallo stato della lista padre), poi agganciate come le altre. + sottoListeModelloIds?: string[]; +} + +// Valida le sotto-liste esistenti e forka al volo quelle scelte dal catalogo, tutto +// dentro la transazione del chiamante: se la creazione/modifica della lista principale +// fallisce più avanti, anche le liste forkate qui vengono annullate (nessun orfano). +async function risolviSottoListeIds( + tx: Prisma.TransactionClient, + viewer: Viewer, + listaIdCorrente: string | undefined, + statoListaCorrente: StatoLista, + sottoListe: SottoListeInput, +): Promise { + const sottoListeIds = sottoListe.sottoListeIds ?? []; + await validaSottoListe(tx, viewer, listaIdCorrente, statoListaCorrente, sottoListeIds); + + const forkateIds: string[] = []; + for (const sorgenteId of sottoListe.sottoListeModelloIds ?? []) { + const forkata = await forkLista(tx, sorgenteId, viewer); + forkateIds.push(forkata.id); + } + + return [...sottoListeIds, ...forkateIds]; +} + +export async function creaLista( + nome: string, + stato: StatoLista, + voci: ListaVoceInput[] = [], + sottoListe: SottoListeInput = {}, + viewer: Viewer, + tipoEventoId?: string, +): Promise { + const orgId = risolviOrgId(stato, viewer.orgId); + const statoModerazione = risolviStatoModerazione(stato, viewer); + const lista = await prisma.$transaction(async (tx) => { + const sottoListeIds = await risolviSottoListeIds(tx, viewer, undefined, stato, sottoListe); + return listeRepository.create( + { nome, orgId, creataDaUserId: viewer.userId, stato, statoModerazione, tipoEventoId, voci, sottoListeIds }, + tx, + ); + }); + if (statoModerazione === 'proposto') { + await creaNotificaModerazione( + 'LISTA_PROPOSTA', + `Nuova lista proposta: "${lista.nome}"`, + '/tassonomie?tab=liste', + ); + } + return toView(lista, viewer); +} + +// Autorizza la gestione (update/delete) di una lista: il creatore può sempre gestire le +// proprie liste (qualunque stato); una lista 'gruppo' può essere gestita da chiunque +// abbia quell'organizzazione attiva, non solo da chi l'ha creata (comportamento +// collaborativo). In ogni altro caso, 404 (mai 403, per non rivelare l'esistenza della +// lista a chi non può vederla). +async function assicuraAccessoGestione(id: string, viewer: Viewer): Promise { + const lista = await listeRepository.findById(id); + const autorizzato = + !!lista && (lista.creataDaUserId === viewer.userId || (lista.stato === 'gruppo' && lista.orgId === viewer.orgId)); + if (!autorizzato) { + throw new HttpError(404, 'Lista non trovata'); + } + return lista!; } export interface AggiornaListaInput { nome?: string; + // Permette di promuovere una lista bozza/privata a pubblico (o viceversa) anche in + // edit, non solo alla creazione. + stato?: StatoLista; voci?: ListaVoceInput[]; + sottoListe?: SottoListeInput; + // undefined = non toccare il campo, null = rimuovi il tipo evento associato. + tipoEventoId?: string | null; } -export async function aggiornaLista(id: string, orgId: string, input: AggiornaListaInput): Promise { - await assicuraListaDiOrg(id, orgId); +export async function aggiornaLista(id: string, input: AggiornaListaInput, viewer: Viewer): Promise { + const listaEsistente = await assicuraAccessoGestione(id, viewer); + const statoFinale = input.stato ?? listaEsistente.stato; + const statoCambiato = input.stato !== undefined && input.stato !== listaEsistente.stato; + + // Il contenuto di una lista 'gruppo' è collaborativo (chiunque nell'org può + // modificarne voci/sotto-liste/nome), ma togliere la lista dal gruppo (o comunque + // cambiarne lo stato) resta una decisione di chi l'ha creata: altrimenti un membro + // qualsiasi potrebbe far sparire ad altri una lista condivisa cambiandola in + // bozza/privato a loro insaputa. + if (listaEsistente.stato === 'gruppo' && statoCambiato && listaEsistente.creataDaUserId !== viewer.userId) { + throw new HttpError(403, 'Solo chi ha creato la lista può cambiarne lo stato'); + } + const contenutoModificato = input.voci !== undefined || input.sottoListe !== undefined; + const orgId = statoCambiato ? risolviOrgId(statoFinale, viewer.orgId) : undefined; + const statoModerazione = risolviStatoModerazioneUpdate( + statoFinale, + statoCambiato, + contenutoModificato, + listaEsistente.statoModerazione, + viewer, + ); + try { - const lista = await listeRepository.update(id, input); - return toView(lista); + const lista = await prisma.$transaction(async (tx) => { + const sottoListeIds = input.sottoListe + ? await risolviSottoListeIds(tx, viewer, id, statoFinale, input.sottoListe) + : undefined; + return listeRepository.update( + id, + { + nome: input.nome, + stato: input.stato, + orgId, + statoModerazione, + tipoEventoId: input.tipoEventoId, + voci: input.voci, + sottoListeIds, + }, + tx, + ); + }); + if (statoModerazione === 'proposto' && listaEsistente.statoModerazione !== 'proposto') { + await creaNotificaModerazione( + 'LISTA_PROPOSTA', + `Nuova lista proposta: "${lista.nome}"`, + '/tassonomie?tab=liste', + ); + } + return toView(lista, viewer); } catch (err) { throw toHttpError(err, 'Lista non trovata', 'Uno dei materiali indicati non esiste'); } } -export async function eliminaLista(id: string, orgId: string): Promise { - await assicuraListaDiOrg(id, orgId); +export async function eliminaLista(id: string, viewer: Viewer): Promise { + await assicuraAccessoGestione(id, viewer); try { await listeRepository.delete(id); } catch (err) { - throw toHttpError(err, 'Lista non trovata', 'Impossibile eliminare la lista: è referenziata da un evento'); + throw toHttpError( + err, + 'Lista non trovata', + "Impossibile eliminare la lista: è referenziata da un evento o usata come sotto-lista di un'altra lista", + ); } } diff --git a/scouthub-magazzino-be/src/services/listeModello.service.ts b/scouthub-magazzino-be/src/services/listeModello.service.ts deleted file mode 100644 index 72a27f8..0000000 --- a/scouthub-magazzino-be/src/services/listeModello.service.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - CreateListaModelloData, - ListaModelloConVoci, - ListaModelloVoceInput, - UpdateListaModelloData, - listeModelloRepository, -} from '../repositories/listeModello.repository'; -import { toHttpError } from '../utils/prisma-errors'; - -export interface ListaModelloVoceView { - materialeId: string; - nome: string; - unitaMisura: string; - quantita: number; -} - -export interface ListaModelloView { - id: string; - nome: string; - tipoEventoId: string; - voci: ListaModelloVoceView[]; -} - -function toView(lista: ListaModelloConVoci): ListaModelloView { - return { - id: lista.id, - nome: lista.nome, - tipoEventoId: lista.tipoEventoId, - voci: lista.voci.map((v) => ({ - materialeId: v.materialeId, - nome: v.materiale.nome, - unitaMisura: v.materiale.unitaMisura, - quantita: v.quantita, - })), - }; -} - -export async function listListeModello(tipoEventoId?: string): Promise { - const liste = await listeModelloRepository.findAll(tipoEventoId); - return liste.map(toView); -} - -export interface CreaListaModelloInput { - nome: string; - tipoEventoId: string; - voci: ListaModelloVoceInput[]; -} - -export async function creaListaModello(input: CreaListaModelloInput): Promise { - const data: CreateListaModelloData = input; - try { - const lista = await listeModelloRepository.create(data); - return toView(lista); - } catch (err) { - throw toHttpError(err, 'Tipo evento non trovato', 'Uno dei materiali indicati non esiste'); - } -} - -export interface AggiornaListaModelloInput { - nome?: string; - tipoEventoId?: string; - voci?: ListaModelloVoceInput[]; -} - -export async function aggiornaListaModello(id: string, input: AggiornaListaModelloInput): Promise { - const data: UpdateListaModelloData = input; - try { - const lista = await listeModelloRepository.update(id, data); - return toView(lista); - } catch (err) { - throw toHttpError(err, 'Lista modello non trovata', 'Riferimento non valido (tipo evento o materiale inesistente)'); - } -} - -export async function eliminaListaModello(id: string): Promise { - try { - await listeModelloRepository.delete(id); - } catch (err) { - throw toHttpError(err, 'Lista modello non trovata', 'Impossibile eliminare la lista modello'); - } -} diff --git a/scouthub-magazzino-be/src/services/magazzino.service.ts b/scouthub-magazzino-be/src/services/magazzino.service.ts index 7b14610..0168739 100644 --- a/scouthub-magazzino-be/src/services/magazzino.service.ts +++ b/scouthub-magazzino-be/src/services/magazzino.service.ts @@ -6,6 +6,7 @@ import { magazzinoRepository, } from '../repositories/magazzino.repository'; import { materialiRepository } from '../repositories/materiali.repository'; +import { gruppiMagazzinoRepository } from '../repositories/gruppiMagazzino.repository'; import { HttpError } from '../errors'; import { toHttpError } from '../utils/prisma-errors'; @@ -19,9 +20,11 @@ export interface MagazzinoVoceView { stato: StatoMagazzinoVoce; posizione: string | null; note: string | null; + gruppoId: string | null; } -function toView(voce: MagazzinoVoceConMateriale): MagazzinoVoceView { +// Esportata perché riusata da gruppiMagazzino.service.ts per le voci annidate nei gruppi. +export function toView(voce: MagazzinoVoceConMateriale): MagazzinoVoceView { return { id: voce.id, orgId: voce.orgId, @@ -32,6 +35,7 @@ function toView(voce: MagazzinoVoceConMateriale): MagazzinoVoceView { stato: voce.stato, posizione: voce.posizione, note: voce.note, + gruppoId: voce.gruppoId, }; } @@ -52,16 +56,29 @@ export function listMagazzinoPerOrg(orgId: string): Promise return magazzinoRepository.findAllByOrg(orgId).then((voci) => voci.map(toView)); } +// Un gruppoId, se indicato, deve esistere ed essere della stessa org: mai +// un id letto/indovinato che porti in un gruppo di un'altra organizzazione. +async function assicuraGruppoDiOrg(gruppoId: string, orgId: string): Promise { + const gruppo = await gruppiMagazzinoRepository.findByIdAndOrg(gruppoId, orgId); + if (!gruppo) { + throw new HttpError(400, 'Il gruppo indicato non esiste o non appartiene alla tua organizzazione'); + } +} + export interface AggiungiVoceInput { materialeId: string; quantitaPosseduta: number; stato: StatoMagazzinoVoce; posizione?: string; note?: string; + gruppoId?: string | null; } export async function aggiungiVoce(orgId: string, input: AggiungiVoceInput): Promise { await assicuraMaterialeApprovato(input.materialeId); + if (input.gruppoId) { + await assicuraGruppoDiOrg(input.gruppoId, orgId); + } const data: CreateMagazzinoVoceData = { ...input, orgId }; const voce = await magazzinoRepository.create(data); @@ -81,6 +98,7 @@ export interface AggiornaVoceInput { stato?: StatoMagazzinoVoce; posizione?: string | null; note?: string | null; + gruppoId?: string | null; } export async function aggiornaVoce(id: string, orgId: string, input: AggiornaVoceInput): Promise { @@ -89,6 +107,9 @@ export async function aggiornaVoce(id: string, orgId: string, input: AggiornaVoc if (input.materialeId !== undefined) { await assicuraMaterialeApprovato(input.materialeId); } + if (input.gruppoId) { + await assicuraGruppoDiOrg(input.gruppoId, orgId); + } const data: UpdateMagazzinoVoceData = input; try { diff --git a/scouthub-magazzino-be/src/services/materiali.service.ts b/scouthub-magazzino-be/src/services/materiali.service.ts index e874bed..02ba0da 100644 --- a/scouthub-magazzino-be/src/services/materiali.service.ts +++ b/scouthub-magazzino-be/src/services/materiali.service.ts @@ -1,6 +1,8 @@ import { Materiale, StatoMateriale } from '@prisma/client'; import { materialiRepository } from '../repositories/materiali.repository'; import { HttpError } from '../errors'; +import { toHttpError } from '../utils/prisma-errors'; +import { creaNotificaModerazione } from './notifiche.service'; export interface MaterialePubblico { id: string; @@ -40,8 +42,8 @@ function toProposta(materiale: Materiale): MaterialeProposta { }; } -export async function listMaterialiApprovati(categoria?: string): Promise { - const materiali = await materialiRepository.findApprovati(categoria); +export async function listMaterialiApprovati(categoria?: string, nome?: string): Promise { + const materiali = await materialiRepository.findApprovati(categoria, nome); return materiali.map(toPubblico); } @@ -59,9 +61,58 @@ export async function proponiMateriale(input: ProponiMaterialeInput): Promise { + const materiale = await materialiRepository.create({ + nome: input.nome, + categoria: input.categoria, + unitaMisura: input.unitaMisura, + propostoDaOrgId: input.orgId, + stato: StatoMateriale.approvato, + }); + return toPubblico(materiale); +} + +export interface AggiornaMaterialeInput { + nome: string; + categoria: string; + unitaMisura: string; +} + +export async function aggiornaMateriale(id: string, input: AggiornaMaterialeInput): Promise { + try { + const materiale = await materialiRepository.update(id, input); + return toPubblico(materiale); + } catch (err) { + throw toHttpError(err, 'Materiale non trovato', "Conflitto durante l'aggiornamento del materiale"); + } +} + +export async function eliminaMateriale(id: string): Promise { + try { + await materialiRepository.delete(id); + } catch (err) { + throw toHttpError(err, 'Materiale non trovato', 'Impossibile eliminare il materiale: è referenziato altrove'); + } +} + export async function listProposte(): Promise { const materiali = await materialiRepository.findProposte(); return materiali.map(toProposta); diff --git a/scouthub-magazzino-be/src/services/notifiche.service.ts b/scouthub-magazzino-be/src/services/notifiche.service.ts new file mode 100644 index 0000000..7622633 --- /dev/null +++ b/scouthub-magazzino-be/src/services/notifiche.service.ts @@ -0,0 +1,55 @@ +import { Notifica, TipoNotifica } from '@prisma/client'; +import { notificheRepository } from '../repositories/notifiche.repository'; +import { HttpError } from '../errors'; + +export interface NotificaView { + id: number; + tipo: TipoNotifica; + messaggio: string; + link: string | null; + letta: boolean; + dataCreazione: Date; +} + +function toView(notifica: Notifica): NotificaView { + return { + id: notifica.id, + tipo: notifica.tipo, + messaggio: notifica.messaggio, + link: notifica.link, + letta: notifica.letta, + dataCreazione: notifica.dataCreazione, + }; +} + +export async function listNotifiche(): Promise { + const notifiche = await notificheRepository.findAll(); + return notifiche.map(toView); +} + +export function countNonLette(): Promise { + return notificheRepository.countNonLette(); +} + +export async function segnaLetta(id: number): Promise { + const notifica = await notificheRepository.findById(id); + if (!notifica) { + throw new HttpError(404, 'Notifica non trovata'); + } + await notificheRepository.segnaLetta(id); +} + +export async function segnaTutteLette(): Promise { + await notificheRepository.segnaTutteLette(); +} + +// Notifica broadcast per la coda di moderazione (visibile solo a chi ha ruolo +// 'moderatore', vedi requireModeratore su notifiche.routes.ts): usata per segnalare +// nuove proposte di materiale/categoria/tipo evento/lista in attesa di revisione. +export async function creaNotificaModerazione( + tipo: TipoNotifica, + messaggio: string, + link?: string, +): Promise { + await notificheRepository.create({ tipo, messaggio, link }); +} diff --git a/scouthub-magazzino-be/src/services/tipiEvento.service.ts b/scouthub-magazzino-be/src/services/tipiEvento.service.ts index dc80a19..6dd2d09 100644 --- a/scouthub-magazzino-be/src/services/tipiEvento.service.ts +++ b/scouthub-magazzino-be/src/services/tipiEvento.service.ts @@ -1,20 +1,45 @@ -import { TipoEvento } from '@prisma/client'; +import { StatoCategoria, TipoEvento } from '@prisma/client'; import { tipiEventoRepository } from '../repositories/tipiEvento.repository'; +import { HttpError } from '../errors'; import { toHttpError } from '../utils/prisma-errors'; +import { creaNotificaModerazione } from './notifiche.service'; + +export function listTipiEventoConfermati(nome?: string): Promise { + return tipiEventoRepository.findConfermati(nome); +} export function listTipiEvento(): Promise { return tipiEventoRepository.findAll(); } export function creaTipoEvento(nome: string): Promise { - return tipiEventoRepository.create(nome); + return tipiEventoRepository.create({ nome, stato: StatoCategoria.confermata }); +} + +export interface ProponiTipoEventoInput { + nome: string; + orgId: string; +} + +export async function proponiTipoEvento(input: ProponiTipoEventoInput): Promise { + const tipoEvento = await tipiEventoRepository.create({ + nome: input.nome, + stato: StatoCategoria.da_approvare, + creatoDaOrgId: input.orgId, + }); + await creaNotificaModerazione( + 'TIPO_EVENTO_PROPOSTO', + `Nuovo tipo evento proposto: "${tipoEvento.nome}"`, + '/tassonomie?tab=tipiEvento', + ); + return tipoEvento; } export async function aggiornaTipoEvento(id: string, nome: string): Promise { try { return await tipiEventoRepository.update(id, nome); } catch (err) { - throw toHttpError(err, 'Tipo evento non trovato', 'Conflitto durante l\'aggiornamento del tipo evento'); + throw toHttpError(err, 'Tipo evento non trovato', "Conflitto durante l'aggiornamento del tipo evento"); } } @@ -29,3 +54,27 @@ export async function eliminaTipoEvento(id: string): Promise { ); } } + +export async function approvaTipoEvento(id: string): Promise { + const tipoEvento = await tipiEventoRepository.findById(id); + if (!tipoEvento) { + throw new HttpError(404, 'Tipo evento non trovato'); + } + if (tipoEvento.stato !== StatoCategoria.da_approvare) { + throw new HttpError(409, 'Il tipo evento è già confermato'); + } + + return tipiEventoRepository.updateStato(id, StatoCategoria.confermata); +} + +export async function rifiutaTipoEvento(id: string): Promise { + const tipoEvento = await tipiEventoRepository.findById(id); + if (!tipoEvento) { + throw new HttpError(404, 'Tipo evento non trovato'); + } + if (tipoEvento.stato !== StatoCategoria.da_approvare) { + throw new HttpError(409, 'Solo le proposte in attesa possono essere rifiutate'); + } + + await tipiEventoRepository.delete(id); +} diff --git a/scouthub-magazzino-be/tests/integration/categorie.endpoint.test.ts b/scouthub-magazzino-be/tests/integration/categorie.endpoint.test.ts new file mode 100644 index 0000000..bfdf2eb --- /dev/null +++ b/scouthub-magazzino-be/tests/integration/categorie.endpoint.test.ts @@ -0,0 +1,237 @@ +import { generateKeyPairSync } from 'crypto'; +import request from 'supertest'; +import nock from 'nock'; +import jwt from 'jsonwebtoken'; + +process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test'; +process.env.KEYCLOAK_REALM = 'scouthub'; +process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client'; +process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret'; +process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test'; + +const categoriaFindMany = jest.fn(); +const categoriaFindUnique = jest.fn(); +const categoriaCreate = jest.fn(); +const categoriaUpdate = jest.fn(); +const categoriaDelete = jest.fn(); + +jest.mock('../../src/db/prisma', () => ({ + prisma: { + categoria: { + findMany: (...args: unknown[]) => categoriaFindMany(...args), + findUnique: (...args: unknown[]) => categoriaFindUnique(...args), + create: (...args: unknown[]) => categoriaCreate(...args), + update: (...args: unknown[]) => categoriaUpdate(...args), + delete: (...args: unknown[]) => categoriaDelete(...args), + }, + }, +})); + +import { app } from '../../src/app'; + +const KEYCLOAK_HOST = 'http://keycloak.test'; +const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs'; +const KID = 'test-kid'; + +const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const jwk = publicKey.export({ format: 'jwk' }) as Record; +const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string; + +function signToken(payload: object): string { + return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' }); +} + +function utenteToken(orgId = 'org-1'): string { + return signToken({ + sub: 'user-1', + realm_access: { roles: ['censito'] }, + organization: { 'gruppo-alfa': { id: orgId, roles: [] } }, + }); +} + +function adminCatalogoToken(): string { + return signToken({ + sub: 'admin-1', + realm_access: { roles: ['moderatore'] }, + organization: { 'gruppo-omega': { id: 'org-9', roles: [] } }, + }); +} + +beforeAll(() => { + nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, { + keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }], + }); +}); + +afterAll(() => { + nock.cleanAll(); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('GET /categorie', () => { + test('un utente normale non può accedere', async () => { + const response = await request(app).get('/categorie').set('Authorization', `Bearer ${utenteToken()}`); + + expect(response.status).toBe(403); + expect(categoriaFindMany).not.toHaveBeenCalled(); + }); + + test('un moderatore vede tutte le categorie', async () => { + categoriaFindMany.mockResolvedValueOnce([{ id: 'c-1', nome: 'Cucina', stato: 'confermata' }]); + + const response = await request(app).get('/categorie').set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual([{ id: 'c-1', nome: 'Cucina', stato: 'confermata' }]); + }); + + test('risponde 401 senza token', async () => { + const response = await request(app).get('/categorie'); + + expect(response.status).toBe(401); + expect(categoriaFindMany).not.toHaveBeenCalled(); + }); +}); + +describe('POST /categorie', () => { + test('un utente normale non può creare una categoria direttamente', async () => { + const response = await request(app) + .post('/categorie') + .set('Authorization', `Bearer ${utenteToken()}`) + .send({ nome: 'Campeggio' }); + + expect(response.status).toBe(403); + expect(categoriaCreate).not.toHaveBeenCalled(); + }); + + test('un moderatore crea una categoria già confermata', async () => { + categoriaCreate.mockResolvedValueOnce({ id: 'c-2', nome: 'Campeggio', stato: 'confermata' }); + + const response = await request(app) + .post('/categorie') + .set('Authorization', `Bearer ${adminCatalogoToken()}`) + .send({ nome: 'Campeggio' }); + + expect(response.status).toBe(201); + expect(categoriaCreate).toHaveBeenCalledWith({ data: { nome: 'Campeggio', stato: 'confermata' } }); + }); +}); + +describe('PUT /categorie/:id', () => { + test('un moderatore può modificare una categoria', async () => { + categoriaUpdate.mockResolvedValueOnce({ id: 'c-1', nome: 'Cucina da campo', stato: 'confermata' }); + + const response = await request(app) + .put('/categorie/c-1') + .set('Authorization', `Bearer ${adminCatalogoToken()}`) + .send({ nome: 'Cucina da campo' }); + + expect(response.status).toBe(200); + expect(categoriaUpdate).toHaveBeenCalledWith({ where: { id: 'c-1' }, data: { nome: 'Cucina da campo' } }); + }); +}); + +describe('DELETE /categorie/:id', () => { + test('un moderatore può eliminare una categoria', async () => { + categoriaDelete.mockResolvedValueOnce({ id: 'c-1' }); + + const response = await request(app).delete('/categorie/c-1').set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(204); + expect(categoriaDelete).toHaveBeenCalledWith({ where: { id: 'c-1' } }); + }); +}); + +describe('POST /categorie/proposte', () => { + test("un utente autenticato con un'org propone una categoria, che nasce 'da_approvare'", async () => { + categoriaCreate.mockResolvedValueOnce({ + id: 'c-3', + nome: 'Escursionismo', + stato: 'da_approvare', + creatoDaOrgId: 'org-1', + }); + + const response = await request(app) + .post('/categorie/proposte') + .set('Authorization', `Bearer ${utenteToken('org-1')}`) + .send({ nome: 'Escursionismo' }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ stato: 'da_approvare', creatoDaOrgId: 'org-1' }); + expect(categoriaCreate).toHaveBeenCalledWith({ + data: { nome: 'Escursionismo', stato: 'da_approvare', creatoDaOrgId: 'org-1' }, + }); + }); + + test('risponde 401 senza token', async () => { + const response = await request(app).post('/categorie/proposte').send({ nome: 'Escursionismo' }); + + expect(response.status).toBe(401); + expect(categoriaCreate).not.toHaveBeenCalled(); + }); +}); + +describe('POST /categorie/:id/approva', () => { + test('un moderatore può approvare una categoria proposta', async () => { + categoriaFindUnique.mockResolvedValueOnce({ id: 'c-3', nome: 'Escursionismo', stato: 'da_approvare' }); + categoriaUpdate.mockResolvedValueOnce({ id: 'c-3', nome: 'Escursionismo', stato: 'confermata' }); + + const response = await request(app) + .post('/categorie/c-3/approva') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(200); + expect(response.body.stato).toBe('confermata'); + expect(categoriaUpdate).toHaveBeenCalledWith({ where: { id: 'c-3' }, data: { stato: 'confermata' } }); + }); + + test('risponde 409 se la categoria è già confermata', async () => { + categoriaFindUnique.mockResolvedValueOnce({ id: 'c-1', nome: 'Cucina', stato: 'confermata' }); + + const response = await request(app) + .post('/categorie/c-1/approva') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(409); + expect(categoriaUpdate).not.toHaveBeenCalled(); + }); + + test('risponde 404 se la categoria non esiste', async () => { + categoriaFindUnique.mockResolvedValueOnce(null); + + const response = await request(app) + .post('/categorie/inesistente/approva') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(404); + expect(categoriaUpdate).not.toHaveBeenCalled(); + }); +}); + +describe('POST /categorie/:id/rifiuta', () => { + test('un moderatore può rifiutare una categoria proposta, che viene eliminata', async () => { + categoriaFindUnique.mockResolvedValueOnce({ id: 'c-3', nome: 'Escursionismo', stato: 'da_approvare' }); + categoriaDelete.mockResolvedValueOnce({ id: 'c-3' }); + + const response = await request(app) + .post('/categorie/c-3/rifiuta') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(204); + expect(categoriaDelete).toHaveBeenCalledWith({ where: { id: 'c-3' } }); + }); + + test('risponde 409 se la categoria è già confermata', async () => { + categoriaFindUnique.mockResolvedValueOnce({ id: 'c-1', nome: 'Cucina', stato: 'confermata' }); + + const response = await request(app) + .post('/categorie/c-1/rifiuta') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(409); + expect(categoriaDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/scouthub-magazzino-be/tests/integration/liste.endpoint.test.ts b/scouthub-magazzino-be/tests/integration/liste.endpoint.test.ts index bc2e46d..66903a8 100644 --- a/scouthub-magazzino-be/tests/integration/liste.endpoint.test.ts +++ b/scouthub-magazzino-be/tests/integration/liste.endpoint.test.ts @@ -15,21 +15,18 @@ const listaCreate = jest.fn(); const listaUpdate = jest.fn(); const listaDelete = jest.fn(); const listaVoceDeleteMany = jest.fn(); +const listaSottoListaDeleteMany = jest.fn(); +const listaSottoListaFindMany = jest.fn(); -const listaModelloFindUnique = jest.fn(); -const listaModelloUpdate = jest.fn(); -const listaModelloVoceDeleteMany = jest.fn(); - -// $transaction condiviso da entrambi i repository (liste e liste-modello): il tx -// espone gli stessi metodi mockati usati fuori transazione, così i test possono -// asserire su un'unica lista di chiamate indipendentemente dal fatto che passino -// per una transazione o meno. +// $transaction espone lo stesso client mockato usato fuori transazione, così i test +// possono asserire sull'unica lista di chiamate indipendentemente dal fatto che +// passino per una transazione o meno (creaLista/aggiornaLista aprono sempre una +// propria transazione per orchestrare fork + aggancio sotto-liste atomicamente). const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) => callback({ - lista: { update: listaUpdate, delete: listaDelete }, + lista: { create: listaCreate, update: listaUpdate, delete: listaDelete, findMany: listaFindMany, findFirst: listaFindFirst }, listaVoce: { deleteMany: listaVoceDeleteMany }, - listaModello: { update: listaModelloUpdate }, - listaModelloVoce: { deleteMany: listaModelloVoceDeleteMany }, + listaSottoLista: { deleteMany: listaSottoListaDeleteMany, findMany: listaSottoListaFindMany }, }), ); @@ -39,16 +36,15 @@ jest.mock('../../src/db/prisma', () => ({ findMany: (...args: unknown[]) => listaFindMany(...args), findFirst: (...args: unknown[]) => listaFindFirst(...args), create: (...args: unknown[]) => listaCreate(...args), + update: (...args: unknown[]) => listaUpdate(...args), + delete: (...args: unknown[]) => listaDelete(...args), }, listaVoce: { deleteMany: (...args: unknown[]) => listaVoceDeleteMany(...args), }, - listaModello: { - findUnique: (...args: unknown[]) => listaModelloFindUnique(...args), - update: (...args: unknown[]) => listaModelloUpdate(...args), - }, - listaModelloVoce: { - deleteMany: (...args: unknown[]) => listaModelloVoceDeleteMany(...args), + listaSottoLista: { + deleteMany: (...args: unknown[]) => listaSottoListaDeleteMany(...args), + findMany: (...args: unknown[]) => listaSottoListaFindMany(...args), }, $transaction: (...args: unknown[]) => transactionFn(...(args as [(tx: unknown) => unknown])), }, @@ -68,14 +64,24 @@ function signToken(payload: object): string { return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' }); } -function tokenOrg(orgId: string): string { +function tokenOrgUser(orgId: string, sub: string): string { return signToken({ - sub: 'user-1', + sub, realm_access: { roles: ['censito'] }, organization: { gruppo: { id: orgId, roles: [] } }, }); } +function tokenOrg(orgId: string): string { + return tokenOrgUser(orgId, 'user-1'); +} + +// Utente autenticato ma senza alcuna organizzazione attiva sul token: deve comunque +// poter creare/gestire le proprie liste personali (bozza/privato/pubblico). +function tokenSenzaOrg(sub = 'user-1'): string { + return signToken({ sub, realm_access: { roles: ['censito'] } }); +} + function adminCatalogoToken(): string { return signToken({ sub: 'admin-1', @@ -88,6 +94,30 @@ function materialeJoin(id: string, nome: string, unitaMisura: string) { return { id, nome, categoria: 'x', unitaMisura, stato: 'approvato', propostoDaOrgId: 'org-seed', creatoIl: new Date() }; } +// Mirror dell'include usato da liste.repository.ts: le assert sulle chiamate a +// prisma.lista.create/findFirst/findMany lo confrontano per intero. +const INCLUDE_VOCI = { + voci: { include: { materiale: true } }, + sottoListe: { include: { sottoLista: { include: { voci: { include: { materiale: true } } } } } }, +}; + +function listaVuota(overrides: Record = {}) { + return { + id: 'l-1', + nome: 'Lista', + orgId: null, + stato: 'bozza', + statoModerazione: null, + tipoEventoId: null, + parentId: null, + creataDaUserId: 'user-1', + creataIl: new Date(), + voci: [], + sottoListe: [], + ...overrides, + }; +} + beforeAll(() => { nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, { keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }], @@ -103,24 +133,24 @@ beforeEach(() => { }); describe('GET /liste', () => { - test('restituisce solo le liste dell\'org corrente, ricavata dal token', async () => { + test('utente con org: filtra su proprie liste (qualunque stato) + liste di gruppo della propria org', async () => { listaFindMany.mockResolvedValueOnce([]); await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`); expect(listaFindMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { orgId: 'org-a' } }), + expect.objectContaining({ + where: { OR: [{ creataDaUserId: 'user-1' }, { stato: 'gruppo', orgId: 'org-a' }] }, + }), ); }); - test('org diverse ottengono query filtrate su org_id diversi', async () => { - listaFindMany.mockResolvedValue([]); + test('utente senza org: filtra solo sulle proprie liste', async () => { + listaFindMany.mockResolvedValueOnce([]); - await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`); - await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-b')}`); + await request(app).get('/liste').set('Authorization', `Bearer ${tokenSenzaOrg()}`); - expect(listaFindMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: { orgId: 'org-a' } })); - expect(listaFindMany).toHaveBeenNthCalledWith(2, expect.objectContaining({ where: { orgId: 'org-b' } })); + expect(listaFindMany).toHaveBeenCalledWith(expect.objectContaining({ where: { OR: [{ creataDaUserId: 'user-1' }] } })); }); test('risponde 401 senza token', async () => { @@ -129,11 +159,90 @@ describe('GET /liste', () => { expect(response.status).toBe(401); expect(listaFindMany).not.toHaveBeenCalled(); }); + + test('marca inAttesaConferma solo per il creatore della lista, mai per altri membri dell\'org', async () => { + listaFindMany.mockResolvedValueOnce([ + listaVuota({ + stato: 'gruppo', + orgId: 'org-a', + creataDaUserId: 'user-1', + voci: [{ materialeId: 'm-1', quantita: 1, materiale: { ...materialeJoin('m-1', 'Tenda', 'pz'), stato: 'proposto' } }], + }), + ]); + + const response = await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`); + + expect(response.status).toBe(200); + expect(response.body[0].voci[0].inAttesaConferma).toBe(true); + }); + + test('non marca inAttesaConferma per chi non ha creato la lista e non è moderatore', async () => { + listaFindMany.mockResolvedValueOnce([ + listaVuota({ + stato: 'gruppo', + orgId: 'org-a', + creataDaUserId: 'un-altro-utente', + voci: [{ materialeId: 'm-1', quantita: 1, materiale: { ...materialeJoin('m-1', 'Tenda', 'pz'), stato: 'proposto' } }], + }), + ]); + + const response = await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`); + + expect(response.status).toBe(200); + expect(response.body[0].voci[0].inAttesaConferma).toBeUndefined(); + }); +}); + +describe('GET /liste/pubbliche', () => { + test('nessun token richiesto: restituisce le liste pubbliche già approvate', async () => { + listaFindMany.mockResolvedValueOnce([ + listaVuota({ id: 'l-pub', stato: 'pubblico', statoModerazione: 'approvato', creataDaUserId: 'chiunque' }), + ]); + + const response = await request(app).get('/liste/pubbliche'); + + expect(response.status).toBe(200); + expect(listaFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { stato: 'pubblico', statoModerazione: 'approvato' } }), + ); + expect(response.body[0].id).toBe('l-pub'); + }); + + test('filtra per tipoEventoId quando richiesto', async () => { + listaFindMany.mockResolvedValueOnce([]); + + await request(app).get('/liste/pubbliche').query({ tipoEventoId: 't-1' }); + + expect(listaFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { stato: 'pubblico', statoModerazione: 'approvato', tipoEventoId: 't-1' } }), + ); + }); +}); + +describe('GET /liste/pubbliche/:id', () => { + test('restituisce la lista pubblica approvata (deep-link diretto al catalogo)', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-pub', stato: 'pubblico', statoModerazione: 'approvato' })); + + const response = await request(app).get('/liste/pubbliche/l-pub'); + + expect(response.status).toBe(200); + expect(listaFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'l-pub', stato: 'pubblico', statoModerazione: 'approvato' } }), + ); + }); + + test('risponde 404 se non esiste o non è ancora approvata', async () => { + listaFindFirst.mockResolvedValueOnce(null); + + const response = await request(app).get('/liste/pubbliche/inesistente'); + + expect(response.status).toBe(404); + }); }); describe('POST /liste', () => { - test('crea una lista vuota per l\'org corrente, ignorando un org_id eventualmente inviato dal client', async () => { - listaCreate.mockResolvedValueOnce({ id: 'l-1', nome: 'Lista vuota', orgId: 'org-a', creataIl: new Date(), voci: [] }); + test('crea una lista bozza personale (senza orgId) ignorando un org_id inviato dal client, anche con org attiva', async () => { + listaCreate.mockResolvedValueOnce(listaVuota({ nome: 'Lista vuota' })); const response = await request(app) .post('/liste') @@ -141,54 +250,279 @@ describe('POST /liste', () => { .send({ nome: 'Lista vuota', orgId: 'org-spoofed' }); expect(response.status).toBe(201); + expect(response.body.orgId).toBeNull(); expect(listaCreate).toHaveBeenCalledWith({ - data: { nome: 'Lista vuota', orgId: 'org-a', voci: { create: [] } }, - include: { voci: { include: { materiale: true } } }, + data: { + nome: 'Lista vuota', + orgId: null, + creataDaUserId: 'user-1', + stato: 'bozza', + statoModerazione: null, + tipoEventoId: null, + parentId: null, + voci: { create: [] }, + sottoListe: { create: [] }, + }, + include: INCLUDE_VOCI, }); }); + + test('crea una lista pubblica anche senza organizzazione attiva sul token, in attesa di approvazione', async () => { + listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'pubblico', statoModerazione: 'proposto', orgId: null })); + + const response = await request(app) + .post('/liste') + .set('Authorization', `Bearer ${tokenSenzaOrg()}`) + .send({ nome: 'Campo estivo', stato: 'pubblico', voci: [{ materialeId: 'm-1', quantita: 2 }] }); + + expect(response.status).toBe(201); + expect(listaCreate).toHaveBeenCalledWith({ + data: { + nome: 'Campo estivo', + orgId: null, + creataDaUserId: 'user-1', + stato: 'pubblico', + statoModerazione: 'proposto', + tipoEventoId: null, + parentId: null, + voci: { create: [{ materialeId: 'm-1', quantita: 2 }] }, + sottoListe: { create: [] }, + }, + include: INCLUDE_VOCI, + }); + }); + + test('un moderatore può creare direttamente una lista pubblica già approvata (bypass della coda)', async () => { + listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'pubblico', statoModerazione: 'approvato' })); + + await request(app) + .post('/liste') + .set('Authorization', `Bearer ${adminCatalogoToken()}`) + .send({ nome: 'Campo estivo', stato: 'pubblico' }); + + expect(listaCreate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ statoModerazione: 'approvato' }) }), + ); + }); + + test('risponde 400 se si crea una lista di gruppo senza organizzazione attiva', async () => { + const response = await request(app) + .post('/liste') + .set('Authorization', `Bearer ${tokenSenzaOrg()}`) + .send({ nome: 'Campo estivo', stato: 'gruppo' }); + + expect(response.status).toBe(400); + expect(listaCreate).not.toHaveBeenCalled(); + }); + + test('crea una lista di gruppo con l\'org attiva sul token', async () => { + listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'gruppo', orgId: 'org-a' })); + + const response = await request(app) + .post('/liste') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ nome: 'Campo estivo', stato: 'gruppo' }); + + expect(response.status).toBe(201); + expect(listaCreate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ orgId: 'org-a', stato: 'gruppo', statoModerazione: null }) }), + ); + }); + + test('risponde 400 se lo stato indicato non è valido', async () => { + const response = await request(app) + .post('/liste') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ nome: 'Campo estivo', stato: 'inesistente' }); + + expect(response.status).toBe(400); + expect(listaCreate).not.toHaveBeenCalled(); + }); }); -describe('POST /liste/da-modello/:listaModelloId', () => { - test('copia nome e voci dalla lista modello, senza salvare alcun riferimento verso di essa', async () => { - const vociModello = [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }]; - listaModelloFindUnique.mockResolvedValueOnce({ - id: 'lm-1', - nome: 'Kit campo estivo', - tipoEventoId: 't-1', - pubblica: true, - voci: vociModello, - }); - listaCreate.mockResolvedValueOnce({ - id: 'l-2', - nome: 'Kit campo estivo', - orgId: 'org-a', - creataIl: new Date(), - voci: [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }], - }); +describe('POST /liste — sotto-liste', () => { + test('aggancia una lista personale propria come sotto-lista', async () => { + listaSottoListaFindMany.mockResolvedValueOnce([]); // nessuna con proprie sotto-liste + listaFindMany.mockResolvedValueOnce([{ id: 'l-esistente' }]); // visibile come candidata + listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-nuova' })); const response = await request(app) - .post('/liste/da-modello/lm-1') + .post('/liste') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ nome: 'Campo estivo', stato: 'bozza', sottoListeIds: ['l-esistente'] }); + + expect(response.status).toBe(201); + expect(listaFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: { in: ['l-esistente'] }, + OR: [{ creataDaUserId: 'user-1' }, { stato: 'pubblico', statoModerazione: 'approvato' }], + }, + }), + ); + expect(listaCreate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ sottoListe: { create: [{ sottoListaId: 'l-esistente' }] } }) }), + ); + }); + + test('una lista personale (non di gruppo) non può agganciare una lista di gruppo come sotto-lista', async () => { + // Il candidato è di gruppo: la query "visibili" con permettiGruppo=false non lo include mai, + // quindi il mock restituisce vuoto per simulare "non trovato/non utilizzabile". + listaFindMany.mockResolvedValueOnce([]); + + const response = await request(app) + .post('/liste') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ nome: 'Campo estivo', stato: 'bozza', sottoListeIds: ['l-gruppo-altrui'] }); + + expect(response.status).toBe(400); + expect(listaCreate).not.toHaveBeenCalled(); + }); + + test('una lista di gruppo può agganciare sia una propria lista personale sia una lista di gruppo della stessa org', async () => { + listaSottoListaFindMany.mockResolvedValueOnce([]); + listaFindMany.mockResolvedValueOnce([{ id: 'l-personale' }, { id: 'l-gruppo' }]); + listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'gruppo', orgId: 'org-a' })); + + const response = await request(app) + .post('/liste') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ nome: 'Campo estivo', stato: 'gruppo', sottoListeIds: ['l-personale', 'l-gruppo'] }); + + expect(response.status).toBe(201); + expect(listaFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: { in: ['l-personale', 'l-gruppo'] }, + OR: [ + { creataDaUserId: 'user-1' }, + { stato: 'gruppo', orgId: 'org-a' }, + { stato: 'pubblico', statoModerazione: 'approvato' }, + ], + }, + }), + ); + }); + + test('rifiuta una sotto-lista che ha già proprie sotto-liste (nesting a due livelli)', async () => { + listaFindMany.mockResolvedValueOnce([{ id: 'l-annidata' }]); + listaSottoListaFindMany.mockResolvedValueOnce([{ listaId: 'l-annidata' }]); + + const response = await request(app) + .post('/liste') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ nome: 'Campo estivo', stato: 'bozza', sottoListeIds: ['l-annidata'] }); + + expect(response.status).toBe(400); + expect(listaCreate).not.toHaveBeenCalled(); + }); + + test('rifiuta l\'auto-riferimento su update', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1' })); + + const response = await request(app) + .put('/liste/l-1') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ sottoListeIds: ['l-1'] }); + + expect(response.status).toBe(400); + expect(listaUpdate).not.toHaveBeenCalled(); + }); +}); + +describe('POST /liste/da-fork/:id', () => { + test('copia nome/voci dalla lista pubblica di origine in una nuova lista bozza personale, salvando parentId', async () => { + const vociSorgente = [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }]; + listaFindFirst.mockResolvedValueOnce({ + id: 'l-pub-1', + nome: 'Kit campo estivo', + orgId: null, + stato: 'pubblico', + statoModerazione: 'approvato', + tipoEventoId: 't-1', + parentId: null, + creataDaUserId: 'autore-originale', + creataIl: new Date(), + voci: vociSorgente, + sottoListe: [], + }); + listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-2', nome: 'Kit campo estivo', parentId: 'l-pub-1' })); + + const response = await request(app) + .post('/liste/da-fork/l-pub-1') .set('Authorization', `Bearer ${tokenOrg('org-a')}`); expect(response.status).toBe(201); - expect(JSON.stringify(response.body)).not.toMatch(/listaModello/i); + expect(response.body.parentId).toBe('l-pub-1'); + expect(listaFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'l-pub-1', stato: 'pubblico', statoModerazione: 'approvato' } }), + ); const callArg = listaCreate.mock.calls[0][0]; expect(callArg.data).toEqual({ nome: 'Kit campo estivo', - orgId: 'org-a', + orgId: null, + creataDaUserId: 'user-1', + stato: 'bozza', + statoModerazione: null, + tipoEventoId: 't-1', + parentId: 'l-pub-1', voci: { create: [{ materialeId: 'm-1', quantita: 2 }] }, + sottoListe: { create: [] }, }); // Le voci passate a create sono un array nuovo con valori copiati, non lo - // stesso array (né gli stessi oggetti) restituiti dalla lista modello. - expect(callArg.data.voci.create).not.toBe(vociModello); + // stesso array (né gli stessi oggetti) restituiti dalla lista di origine. + expect(callArg.data.voci.create).not.toBe(vociSorgente); }); - test('risponde 404 se la lista modello non esiste', async () => { - listaModelloFindUnique.mockResolvedValueOnce(null); + test('include anche i materiali delle sotto-liste, sommando le quantità con quelli di primo livello', async () => { + listaFindFirst.mockResolvedValueOnce({ + id: 'l-pub-1', + nome: 'Kit campo estivo', + orgId: null, + stato: 'pubblico', + statoModerazione: 'approvato', + tipoEventoId: 't-1', + parentId: null, + creataDaUserId: 'autore-originale', + creataIl: new Date(), + voci: [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }], + sottoListe: [ + { + sottoLista: { + id: 'l-pub-2', + nome: 'Kit pronto soccorso', + voci: [ + { materialeId: 'm-1', quantita: 1, materiale: materialeJoin('m-1', 'Tenda', 'pz') }, + { materialeId: 'm-2', quantita: 3, materiale: materialeJoin('m-2', 'Garza', 'pz') }, + ], + }, + }, + ], + }); + listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-2', nome: 'Kit campo estivo' })); const response = await request(app) - .post('/liste/da-modello/inesistente') + .post('/liste/da-fork/l-pub-1') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`); + + expect(response.status).toBe(201); + + const callArg = listaCreate.mock.calls[0][0]; + expect(callArg.data.voci.create).toEqual( + expect.arrayContaining([ + { materialeId: 'm-1', quantita: 3 }, + { materialeId: 'm-2', quantita: 3 }, + ]), + ); + expect(callArg.data.voci.create).toHaveLength(2); + }); + + test('risponde 404 se la lista di origine non esiste o non è pubblica approvata', async () => { + listaFindFirst.mockResolvedValueOnce(null); + + const response = await request(app) + .post('/liste/da-fork/inesistente') .set('Authorization', `Bearer ${tokenOrg('org-a')}`); expect(response.status).toBe(404); @@ -196,33 +530,149 @@ describe('POST /liste/da-modello/:listaModelloId', () => { }); test('risponde 401 senza token', async () => { - const response = await request(app).post('/liste/da-modello/lm-1'); + const response = await request(app).post('/liste/da-fork/l-pub-1'); expect(response.status).toBe(401); expect(listaCreate).not.toHaveBeenCalled(); }); }); -describe('PUT /liste/:id — isolamento tra org', () => { - test('un\'org non può modificare una lista di un\'altra org (risponde 404, non 403, per non rivelarne l\'esistenza)', async () => { - // La query combina sempre id + orgId: una lista di un'altra org non viene trovata. - listaFindFirst.mockResolvedValueOnce(null); +describe('GET /liste/proposte', () => { + test('risponde 401 senza token', async () => { + const response = await request(app).get('/liste/proposte'); - const response = await request(app) - .put('/liste/l-1') - .set('Authorization', `Bearer ${tokenOrg('org-b')}`) - .send({ nome: 'Nome modificato' }); + expect(response.status).toBe(401); + expect(listaFindMany).not.toHaveBeenCalled(); + }); - expect(response.status).toBe(404); - expect(listaFindFirst).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 'l-1', orgId: 'org-b' } }), + test('risponde 403 per un utente senza ruolo moderatore', async () => { + const response = await request(app).get('/liste/proposte').set('Authorization', `Bearer ${tokenOrg('org-a')}`); + + expect(response.status).toBe(403); + expect(listaFindMany).not.toHaveBeenCalled(); + }); + + test('elenca le liste pubbliche in attesa di decisione, in ordine FIFO', async () => { + listaFindMany.mockResolvedValueOnce([listaVuota({ id: 'l-p1', stato: 'pubblico', statoModerazione: 'proposto' })]); + + const response = await request(app).get('/liste/proposte').set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(200); + expect(listaFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { stato: 'pubblico', statoModerazione: 'proposto' }, + orderBy: { creataIl: 'asc' }, + }), ); + }); +}); + +describe('PATCH /liste/proposte/:id', () => { + test('risponde 401 senza token', async () => { + const response = await request(app).patch('/liste/proposte/l-1').send({ decisione: 'approvato' }); + + expect(response.status).toBe(401); expect(listaUpdate).not.toHaveBeenCalled(); }); - test('l\'org proprietaria può modificare la propria lista', async () => { - listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Vecchio nome', orgId: 'org-a', creataIl: new Date(), voci: [] }); - listaUpdate.mockResolvedValueOnce({ id: 'l-1', nome: 'Nuovo nome', orgId: 'org-a', creataIl: new Date(), voci: [] }); + test('risponde 403 per un utente senza ruolo moderatore', async () => { + const response = await request(app) + .patch('/liste/proposte/l-1') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ decisione: 'approvato' }); + + expect(response.status).toBe(403); + expect(listaUpdate).not.toHaveBeenCalled(); + }); + + test('risponde 404 se la proposta non esiste', async () => { + listaFindFirst.mockResolvedValueOnce(null); + + const response = await request(app) + .patch('/liste/proposte/inesistente') + .set('Authorization', `Bearer ${adminCatalogoToken()}`) + .send({ decisione: 'approvato' }); + + expect(response.status).toBe(404); + expect(listaUpdate).not.toHaveBeenCalled(); + }); + + test('risponde 409 se la proposta è già stata decisa', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ stato: 'pubblico', statoModerazione: 'approvato' })); + + const response = await request(app) + .patch('/liste/proposte/l-1') + .set('Authorization', `Bearer ${adminCatalogoToken()}`) + .send({ decisione: 'approvato' }); + + expect(response.status).toBe(409); + expect(listaUpdate).not.toHaveBeenCalled(); + }); + + test('approva una proposta', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' })); + listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato' })); + + const response = await request(app) + .patch('/liste/proposte/l-1') + .set('Authorization', `Bearer ${adminCatalogoToken()}`) + .send({ decisione: 'approvato' }); + + expect(response.status).toBe(200); + expect(listaUpdate).toHaveBeenCalledWith({ + where: { id: 'l-1' }, + data: { statoModerazione: 'approvato' }, + include: INCLUDE_VOCI, + }); + }); + + test('rifiuta una proposta', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' })); + listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'rifiutato' })); + + const response = await request(app) + .patch('/liste/proposte/l-1') + .set('Authorization', `Bearer ${adminCatalogoToken()}`) + .send({ decisione: 'rifiutato' }); + + expect(response.status).toBe(200); + expect(listaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ data: { statoModerazione: 'rifiutato' } }), + ); + }); +}); + +describe('PUT /liste/:id — ownership', () => { + test('non si può modificare la lista personale di un altro utente (risponde 404, non 403, per non rivelarne l\'esistenza)', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', creataDaUserId: 'un-altro-utente' })); + + const response = await request(app) + .put('/liste/l-1') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ nome: 'Nome modificato' }); + + expect(response.status).toBe(404); + expect(listaFindFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'l-1' } })); + expect(listaUpdate).not.toHaveBeenCalled(); + }); + + test('non si può modificare una lista di gruppo di un\'altra org', async () => { + listaFindFirst.mockResolvedValueOnce( + listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-b', creataDaUserId: 'un-altro-utente' }), + ); + + const response = await request(app) + .put('/liste/l-1') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ nome: 'Nome modificato' }); + + expect(response.status).toBe(404); + expect(listaUpdate).not.toHaveBeenCalled(); + }); + + test('il creatore può modificare la propria lista personale', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', nome: 'Vecchio nome' })); + listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', nome: 'Nuovo nome' })); const response = await request(app) .put('/liste/l-1') @@ -235,80 +685,179 @@ describe('PUT /liste/:id — isolamento tra org', () => { ); }); + test('un membro dell\'org (non il creatore) può modificare una lista di gruppo della propria org', async () => { + listaFindFirst.mockResolvedValueOnce( + listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'un-altro-utente' }), + ); + listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', nome: 'Nuovo nome' })); + + const response = await request(app) + .put('/liste/l-1') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ nome: 'Nuovo nome' }); + + expect(response.status).toBe(200); + expect(listaUpdate).toHaveBeenCalled(); + }); + test('risponde 401 senza token', async () => { const response = await request(app).put('/liste/l-1').send({ nome: 'x' }); expect(response.status).toBe(401); expect(listaFindFirst).not.toHaveBeenCalled(); }); + + test('un membro dell\'org (non il creatore) non può cambiare lo stato di una lista di gruppo altrui', async () => { + listaFindFirst.mockResolvedValueOnce( + listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'un-altro-utente' }), + ); + + const response = await request(app) + .put('/liste/l-1') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ stato: 'privato' }); + + expect(response.status).toBe(403); + expect(listaUpdate).not.toHaveBeenCalled(); + }); + + test('un membro dell\'org (non il creatore) può modificare voci/nome di una lista di gruppo senza cambiarne lo stato', async () => { + listaFindFirst.mockResolvedValueOnce( + listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'un-altro-utente' }), + ); + listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', nome: 'Nuovo nome' })); + + const response = await request(app) + .put('/liste/l-1') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ nome: 'Nuovo nome', stato: 'gruppo' }); + + expect(response.status).toBe(200); + expect(listaUpdate).toHaveBeenCalled(); + }); + + test('il creatore può cambiare lo stato della propria lista di gruppo', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'user-1' })); + listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'privato', orgId: null })); + + const response = await request(app) + .put('/liste/l-1') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`) + .send({ stato: 'privato' }); + + expect(response.status).toBe(200); + expect(listaUpdate).toHaveBeenCalled(); + }); }); -describe('DELETE /liste/:id — isolamento tra org', () => { - test('un\'org non può eliminare una lista di un\'altra org', async () => { - listaFindFirst.mockResolvedValueOnce(null); +describe('PUT /liste/:id — promozione a pubblico e ricalcolo della moderazione', () => { + test('promuovere una bozza a pubblico forza statoModerazione a "proposto"', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'bozza', statoModerazione: null })); + listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' })); - const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`); + const response = await request(app) + .put('/liste/l-1') + .set('Authorization', `Bearer ${tokenSenzaOrg()}`) + .send({ stato: 'pubblico' }); + + expect(response.status).toBe(200); + expect(listaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ stato: 'pubblico', statoModerazione: 'proposto' }) }), + ); + }); + + test('un update banale (solo nome) su una lista già pubblica non resetta una moderazione già decisa', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato' })); + listaUpdate.mockResolvedValueOnce( + listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato', nome: 'Nuovo nome' }), + ); + + const response = await request(app) + .put('/liste/l-1') + .set('Authorization', `Bearer ${tokenSenzaOrg()}`) + .send({ nome: 'Nuovo nome' }); + + expect(response.status).toBe(200); + expect(listaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ statoModerazione: 'approvato' }) }), + ); + }); + + test('modificare le voci di una lista già pubblica riporta statoModerazione a "proposto"', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato' })); + listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' })); + + const response = await request(app) + .put('/liste/l-1') + .set('Authorization', `Bearer ${tokenSenzaOrg()}`) + .send({ voci: [] }); + + expect(response.status).toBe(200); + expect(listaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ statoModerazione: 'proposto' }) }), + ); + }); +}); + +describe('DELETE /liste/:id — ownership', () => { + test('non si può eliminare la lista personale di un altro utente', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', creataDaUserId: 'un-altro-utente' })); + + const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`); expect(response.status).toBe(404); expect(listaDelete).not.toHaveBeenCalled(); }); - test('l\'org proprietaria può eliminare la propria lista', async () => { - listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Lista', orgId: 'org-a', creataIl: new Date(), voci: [] }); + test('il creatore può eliminare la propria lista', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1' })); listaDelete.mockResolvedValueOnce({ id: 'l-1' }); const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`); expect(response.status).toBe(204); expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-1' } }); + expect(listaSottoListaDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-1' } }); expect(listaDelete).toHaveBeenCalledWith({ where: { id: 'l-1' } }); }); }); -describe('indipendenza tra lista modello e lista forkata', () => { - test('aggiornare la lista modello originale non tocca in alcun modo le tabelle della lista privata forkata', async () => { - listaModelloUpdate.mockResolvedValueOnce({ - id: 'lm-1', - nome: 'Kit aggiornato', - tipoEventoId: 't-1', - pubblica: true, - voci: [], - }); - - const response = await request(app) - .put('/liste-modello/lm-1') - .set('Authorization', `Bearer ${adminCatalogoToken()}`) - .send({ nome: 'Kit aggiornato', voci: [] }); - - expect(response.status).toBe(200); - expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } }); - expect(listaModelloUpdate).toHaveBeenCalled(); - // Nessuna chiamata sulle tabelle delle liste private: le due entità sono - // completamente disgiunte dopo il fork. - expect(listaVoceDeleteMany).not.toHaveBeenCalled(); - expect(listaUpdate).not.toHaveBeenCalled(); - expect(listaDelete).not.toHaveBeenCalled(); - }); - - test('aggiornare la lista privata forkata non tocca in alcun modo le tabelle della lista modello originale', async () => { - listaFindFirst.mockResolvedValueOnce({ id: 'l-2', nome: 'Kit campo estivo', orgId: 'org-a', creataIl: new Date(), voci: [] }); - listaUpdate.mockResolvedValueOnce({ - id: 'l-2', - nome: 'Kit campo estivo (personalizzato)', - orgId: 'org-a', +describe('fork: lineage e indipendenza tra lista di origine e lista forkata', () => { + test('la lista forkata ha parentId popolato verso la lista pubblica di origine', async () => { + listaFindFirst.mockResolvedValueOnce({ + id: 'l-pub-1', + nome: 'Kit', + orgId: null, + stato: 'pubblico', + statoModerazione: 'approvato', + tipoEventoId: null, + parentId: null, + creataDaUserId: 'altro-utente', creataIl: new Date(), voci: [], + sottoListe: [], }); + listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-figlia', parentId: 'l-pub-1' })); const response = await request(app) - .put('/liste/l-2') + .post('/liste/da-fork/l-pub-1') + .set('Authorization', `Bearer ${tokenOrg('org-a')}`); + + expect(response.status).toBe(201); + expect(response.body.parentId).toBe('l-pub-1'); + }); + + test('modificare la lista figlia tocca solo la sua riga: nessuna scrittura sulla lista di origine', async () => { + listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-figlia', parentId: 'l-pub-1' })); + listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-figlia', parentId: 'l-pub-1', nome: 'Personalizzata' })); + + const response = await request(app) + .put('/liste/l-figlia') .set('Authorization', `Bearer ${tokenOrg('org-a')}`) - .send({ nome: 'Kit campo estivo (personalizzato)', voci: [] }); + .send({ nome: 'Personalizzata', voci: [] }); expect(response.status).toBe(200); - expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-2' } }); - expect(listaUpdate).toHaveBeenCalled(); - expect(listaModelloVoceDeleteMany).not.toHaveBeenCalled(); - expect(listaModelloUpdate).not.toHaveBeenCalled(); + expect(listaUpdate).toHaveBeenCalledTimes(1); + expect(listaUpdate).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'l-figlia' } })); }); }); diff --git a/scouthub-magazzino-be/tests/integration/listeModello.endpoint.test.ts b/scouthub-magazzino-be/tests/integration/listeModello.endpoint.test.ts deleted file mode 100644 index 1bde0d3..0000000 --- a/scouthub-magazzino-be/tests/integration/listeModello.endpoint.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { generateKeyPairSync } from 'crypto'; -import request from 'supertest'; -import nock from 'nock'; -import jwt from 'jsonwebtoken'; - -process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test'; -process.env.KEYCLOAK_REALM = 'scouthub'; -process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client'; -process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret'; -process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test'; - -const listaModelloFindMany = jest.fn(); -const listaModelloCreate = jest.fn(); - -// tx espone gli stessi metodi usati dal repository dentro $transaction; i test su -// update/delete forniscono un tx dedicato via mockImplementationOnce. -const listaModelloVoceDeleteMany = jest.fn(); -const listaModelloUpdate = jest.fn(); -const listaModelloDelete = jest.fn(); - -const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) => - callback({ - listaModelloVoce: { deleteMany: listaModelloVoceDeleteMany }, - listaModello: { update: listaModelloUpdate, delete: listaModelloDelete }, - }), -); - -jest.mock('../../src/db/prisma', () => ({ - prisma: { - listaModello: { - findMany: (...args: unknown[]) => listaModelloFindMany(...args), - create: (...args: unknown[]) => listaModelloCreate(...args), - }, - $transaction: (...args: unknown[]) => transactionFn(...(args as [(tx: unknown) => unknown])), - }, -})); - -import { app } from '../../src/app'; - -const KEYCLOAK_HOST = 'http://keycloak.test'; -const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs'; -const KID = 'test-kid'; - -const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); -const jwk = publicKey.export({ format: 'jwk' }) as Record; -const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string; - -function signToken(payload: object): string { - return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' }); -} - -function utenteToken(): string { - return signToken({ - sub: 'user-1', - realm_access: { roles: ['censito'] }, - organization: { 'gruppo-alfa': { id: 'org-1', roles: [] } }, - }); -} - -function adminCatalogoToken(): string { - return signToken({ - sub: 'admin-1', - realm_access: { roles: ['moderatore'] }, - organization: { 'gruppo-omega': { id: 'org-9', roles: [] } }, - }); -} - -beforeAll(() => { - nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, { - keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }], - }); -}); - -afterAll(() => { - nock.cleanAll(); -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('GET /liste-modello', () => { - test('è pubblico e restituisce le liste con le voci (materiale + quantità)', async () => { - listaModelloFindMany.mockResolvedValueOnce([ - { - id: 'lm-1', - nome: 'Kit campo estivo', - tipoEventoId: 't-1', - pubblica: true, - voci: [{ materialeId: 'm-1', quantita: 2, materiale: { id: 'm-1', nome: 'Tenda', unitaMisura: 'pz' } }], - }, - ]); - - const response = await request(app).get('/liste-modello'); - - expect(response.status).toBe(200); - expect(response.body).toEqual([ - { - id: 'lm-1', - nome: 'Kit campo estivo', - tipoEventoId: 't-1', - voci: [{ materialeId: 'm-1', nome: 'Tenda', unitaMisura: 'pz', quantita: 2 }], - }, - ]); - expect(listaModelloFindMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { pubblica: true } }), - ); - }); - - test('filtra per tipoEventoId quando richiesto', async () => { - listaModelloFindMany.mockResolvedValueOnce([]); - - await request(app).get('/liste-modello').query({ tipoEventoId: 't-1' }); - - expect(listaModelloFindMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { pubblica: true, tipoEventoId: 't-1' } }), - ); - }); -}); - -describe('POST /liste-modello', () => { - const body = { nome: 'Kit bivacco', tipoEventoId: 't-2', voci: [{ materialeId: 'm-1', quantita: 3 }] }; - - test('un utente normale non può creare una lista modello', async () => { - const response = await request(app) - .post('/liste-modello') - .set('Authorization', `Bearer ${utenteToken()}`) - .send(body); - - expect(response.status).toBe(403); - expect(listaModelloCreate).not.toHaveBeenCalled(); - }); - - test('un moderatore può creare una lista modello, sempre pubblica', async () => { - listaModelloCreate.mockResolvedValueOnce({ - id: 'lm-2', - nome: 'Kit bivacco', - tipoEventoId: 't-2', - pubblica: true, - voci: [{ materialeId: 'm-1', quantita: 3, materiale: { id: 'm-1', nome: 'Corda', unitaMisura: 'm' } }], - }); - - const response = await request(app) - .post('/liste-modello') - .set('Authorization', `Bearer ${adminCatalogoToken()}`) - .send(body); - - expect(response.status).toBe(201); - expect(listaModelloCreate).toHaveBeenCalledWith({ - data: { - nome: 'Kit bivacco', - tipoEventoId: 't-2', - pubblica: true, - voci: { create: [{ materialeId: 'm-1', quantita: 3 }] }, - }, - include: { voci: { include: { materiale: true } } }, - }); - }); - - test('ignora un eventuale pubblica:false inviato dal client, resta sempre true', async () => { - listaModelloCreate.mockResolvedValueOnce({ - id: 'lm-3', - nome: 'Kit bivacco', - tipoEventoId: 't-2', - pubblica: true, - voci: [], - }); - - await request(app) - .post('/liste-modello') - .set('Authorization', `Bearer ${adminCatalogoToken()}`) - .send({ ...body, voci: [], pubblica: false }); - - expect(listaModelloCreate).toHaveBeenCalledWith( - expect.objectContaining({ data: expect.objectContaining({ pubblica: true }) }), - ); - }); - - test('risponde 401 senza token', async () => { - const response = await request(app).post('/liste-modello').send(body); - - expect(response.status).toBe(401); - expect(listaModelloCreate).not.toHaveBeenCalled(); - }); -}); - -describe('PUT /liste-modello/:id', () => { - test('un utente normale non può modificare una lista modello', async () => { - const response = await request(app) - .put('/liste-modello/lm-1') - .set('Authorization', `Bearer ${utenteToken()}`) - .send({ nome: 'Kit aggiornato' }); - - expect(response.status).toBe(403); - expect(listaModelloUpdate).not.toHaveBeenCalled(); - }); - - test('un moderatore può modificare nome e voci di una lista modello', async () => { - listaModelloUpdate.mockResolvedValueOnce({ - id: 'lm-1', - nome: 'Kit aggiornato', - tipoEventoId: 't-1', - pubblica: true, - voci: [{ materialeId: 'm-2', quantita: 1, materiale: { id: 'm-2', nome: 'Torcia', unitaMisura: 'pz' } }], - }); - - const response = await request(app) - .put('/liste-modello/lm-1') - .set('Authorization', `Bearer ${adminCatalogoToken()}`) - .send({ nome: 'Kit aggiornato', voci: [{ materialeId: 'm-2', quantita: 1 }] }); - - expect(response.status).toBe(200); - expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } }); - expect(listaModelloUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'lm-1' }, - data: expect.objectContaining({ - nome: 'Kit aggiornato', - voci: { create: [{ materialeId: 'm-2', quantita: 1 }] }, - }), - }), - ); - }); -}); - -describe('DELETE /liste-modello/:id', () => { - test('un utente normale non può eliminare una lista modello', async () => { - const response = await request(app).delete('/liste-modello/lm-1').set('Authorization', `Bearer ${utenteToken()}`); - - expect(response.status).toBe(403); - expect(listaModelloDelete).not.toHaveBeenCalled(); - }); - - test('un moderatore può eliminare una lista modello (e le sue voci)', async () => { - listaModelloDelete.mockResolvedValueOnce({ id: 'lm-1' }); - - const response = await request(app) - .delete('/liste-modello/lm-1') - .set('Authorization', `Bearer ${adminCatalogoToken()}`); - - expect(response.status).toBe(204); - expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } }); - expect(listaModelloDelete).toHaveBeenCalledWith({ where: { id: 'lm-1' } }); - }); - - test('risponde 401 senza token', async () => { - const response = await request(app).delete('/liste-modello/lm-1'); - - expect(response.status).toBe(401); - expect(listaModelloDelete).not.toHaveBeenCalled(); - }); -}); diff --git a/scouthub-magazzino-be/tests/integration/tipiEvento.endpoint.test.ts b/scouthub-magazzino-be/tests/integration/tipiEvento.endpoint.test.ts index cb3c671..6358bc5 100644 --- a/scouthub-magazzino-be/tests/integration/tipiEvento.endpoint.test.ts +++ b/scouthub-magazzino-be/tests/integration/tipiEvento.endpoint.test.ts @@ -10,6 +10,7 @@ process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret'; process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test'; const tipoEventoFindMany = jest.fn(); +const tipoEventoFindUnique = jest.fn(); const tipoEventoCreate = jest.fn(); const tipoEventoUpdate = jest.fn(); const tipoEventoDelete = jest.fn(); @@ -18,6 +19,7 @@ jest.mock('../../src/db/prisma', () => ({ prisma: { tipoEvento: { findMany: (...args: unknown[]) => tipoEventoFindMany(...args), + findUnique: (...args: unknown[]) => tipoEventoFindUnique(...args), create: (...args: unknown[]) => tipoEventoCreate(...args), update: (...args: unknown[]) => tipoEventoUpdate(...args), delete: (...args: unknown[]) => tipoEventoDelete(...args), @@ -39,11 +41,11 @@ function signToken(payload: object): string { return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' }); } -function utenteToken(): string { +function utenteToken(orgId = 'org-1'): string { return signToken({ sub: 'user-1', realm_access: { roles: ['censito'] }, - organization: { 'gruppo-alfa': { id: 'org-1', roles: [] } }, + organization: { 'gruppo-alfa': { id: orgId, roles: [] } }, }); } @@ -70,13 +72,49 @@ beforeEach(() => { }); describe('GET /tipi-evento', () => { - test('è pubblico e restituisce la lista', async () => { - tipoEventoFindMany.mockResolvedValueOnce([{ id: 't-1', nome: 'Campo estivo' }]); + test('è pubblico e restituisce solo i tipi evento confermati', async () => { + tipoEventoFindMany.mockResolvedValueOnce([{ id: 't-1', nome: 'Campo estivo', stato: 'confermata' }]); const response = await request(app).get('/tipi-evento'); expect(response.status).toBe(200); - expect(response.body).toEqual([{ id: 't-1', nome: 'Campo estivo' }]); + expect(response.body).toEqual([{ id: 't-1', nome: 'Campo estivo', stato: 'confermata' }]); + expect(tipoEventoFindMany).toHaveBeenCalledWith({ + where: { stato: 'confermata' }, + orderBy: { nome: 'asc' }, + }); + }); +}); + +describe('GET /tipi-evento/moderazione', () => { + test('un utente normale non può accedere', async () => { + const response = await request(app) + .get('/tipi-evento/moderazione') + .set('Authorization', `Bearer ${utenteToken()}`); + + expect(response.status).toBe(403); + expect(tipoEventoFindMany).not.toHaveBeenCalled(); + }); + + test('un moderatore vede tutti i tipi evento, incluse le proposte', async () => { + tipoEventoFindMany.mockResolvedValueOnce([ + { id: 't-1', nome: 'Campo estivo', stato: 'confermata' }, + { id: 't-2', nome: 'Bivacco', stato: 'da_approvare' }, + ]); + + const response = await request(app) + .get('/tipi-evento/moderazione') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + }); + + test('risponde 401 senza token', async () => { + const response = await request(app).get('/tipi-evento/moderazione'); + + expect(response.status).toBe(401); + expect(tipoEventoFindMany).not.toHaveBeenCalled(); }); }); @@ -91,8 +129,8 @@ describe('POST /tipi-evento', () => { expect(tipoEventoCreate).not.toHaveBeenCalled(); }); - test('un moderatore può creare un tipo evento', async () => { - tipoEventoCreate.mockResolvedValueOnce({ id: 't-2', nome: 'Bivacco' }); + test('un moderatore crea un tipo evento già confermato', async () => { + tipoEventoCreate.mockResolvedValueOnce({ id: 't-2', nome: 'Bivacco', stato: 'confermata' }); const response = await request(app) .post('/tipi-evento') @@ -100,7 +138,7 @@ describe('POST /tipi-evento', () => { .send({ nome: 'Bivacco' }); expect(response.status).toBe(201); - expect(tipoEventoCreate).toHaveBeenCalledWith({ data: { nome: 'Bivacco' } }); + expect(tipoEventoCreate).toHaveBeenCalledWith({ data: { nome: 'Bivacco', stato: 'confermata' } }); }); test('risponde 401 senza token', async () => { @@ -111,6 +149,35 @@ describe('POST /tipi-evento', () => { }); }); +describe('POST /tipi-evento/proposte', () => { + test("un utente autenticato con un'org propone un tipo evento, che nasce 'da_approvare'", async () => { + tipoEventoCreate.mockResolvedValueOnce({ + id: 't-3', + nome: 'Uscita notturna', + stato: 'da_approvare', + creatoDaOrgId: 'org-1', + }); + + const response = await request(app) + .post('/tipi-evento/proposte') + .set('Authorization', `Bearer ${utenteToken('org-1')}`) + .send({ nome: 'Uscita notturna' }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ stato: 'da_approvare', creatoDaOrgId: 'org-1' }); + expect(tipoEventoCreate).toHaveBeenCalledWith({ + data: { nome: 'Uscita notturna', stato: 'da_approvare', creatoDaOrgId: 'org-1' }, + }); + }); + + test('risponde 401 senza token', async () => { + const response = await request(app).post('/tipi-evento/proposte').send({ nome: 'Uscita notturna' }); + + expect(response.status).toBe(401); + expect(tipoEventoCreate).not.toHaveBeenCalled(); + }); +}); + describe('PUT /tipi-evento/:id', () => { test('un utente normale non può modificare un tipo evento', async () => { const response = await request(app) @@ -159,3 +226,65 @@ describe('DELETE /tipi-evento/:id', () => { expect(tipoEventoDelete).not.toHaveBeenCalled(); }); }); + +describe('POST /tipi-evento/:id/approva', () => { + test('un moderatore può approvare un tipo evento proposto', async () => { + tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-3', nome: 'Uscita notturna', stato: 'da_approvare' }); + tipoEventoUpdate.mockResolvedValueOnce({ id: 't-3', nome: 'Uscita notturna', stato: 'confermata' }); + + const response = await request(app) + .post('/tipi-evento/t-3/approva') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(200); + expect(response.body.stato).toBe('confermata'); + expect(tipoEventoUpdate).toHaveBeenCalledWith({ where: { id: 't-3' }, data: { stato: 'confermata' } }); + }); + + test('risponde 409 se il tipo evento è già confermato', async () => { + tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-1', nome: 'Campo estivo', stato: 'confermata' }); + + const response = await request(app) + .post('/tipi-evento/t-1/approva') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(409); + expect(tipoEventoUpdate).not.toHaveBeenCalled(); + }); + + test('risponde 404 se il tipo evento non esiste', async () => { + tipoEventoFindUnique.mockResolvedValueOnce(null); + + const response = await request(app) + .post('/tipi-evento/inesistente/approva') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(404); + expect(tipoEventoUpdate).not.toHaveBeenCalled(); + }); +}); + +describe('POST /tipi-evento/:id/rifiuta', () => { + test('un moderatore può rifiutare un tipo evento proposto, che viene eliminato', async () => { + tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-3', nome: 'Uscita notturna', stato: 'da_approvare' }); + tipoEventoDelete.mockResolvedValueOnce({ id: 't-3' }); + + const response = await request(app) + .post('/tipi-evento/t-3/rifiuta') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(204); + expect(tipoEventoDelete).toHaveBeenCalledWith({ where: { id: 't-3' } }); + }); + + test('risponde 409 se il tipo evento è già confermato', async () => { + tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-1', nome: 'Campo estivo', stato: 'confermata' }); + + const response = await request(app) + .post('/tipi-evento/t-1/rifiuta') + .set('Authorization', `Bearer ${adminCatalogoToken()}`); + + expect(response.status).toBe(409); + expect(tipoEventoDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/scouthub-magazzino-fe/angular.json b/scouthub-magazzino-fe/angular.json index 86aa620..880de7f 100644 --- a/scouthub-magazzino-fe/angular.json +++ b/scouthub-magazzino-fe/angular.json @@ -24,7 +24,7 @@ "input": "public" } ], - "styles": ["src/material-theme.scss", "src/styles.css"] + "styles": ["src/styles.css"] }, "configurations": { "production": { diff --git a/scouthub-magazzino-fe/src/app/app.css b/scouthub-magazzino-fe/src/app/app.css index e69de29..6babcff 100644 --- a/scouthub-magazzino-fe/src/app/app.css +++ b/scouthub-magazzino-fe/src/app/app.css @@ -0,0 +1,3 @@ +.app-shell { + min-height: 100vh; +} diff --git a/scouthub-magazzino-fe/src/app/app.html b/scouthub-magazzino-fe/src/app/app.html index 67e7bd4..ced555a 100644 --- a/scouthub-magazzino-fe/src/app/app.html +++ b/scouthub-magazzino-fe/src/app/app.html @@ -1 +1,5 @@ - +
+ + +
+ diff --git a/scouthub-magazzino-fe/src/app/app.routes.ts b/scouthub-magazzino-fe/src/app/app.routes.ts index fe8f985..155787a 100644 --- a/scouthub-magazzino-fe/src/app/app.routes.ts +++ b/scouthub-magazzino-fe/src/app/app.routes.ts @@ -15,11 +15,7 @@ export const routes: Routes = [ loadChildren: () => import('./magazzino/magazzino.routes').then((m) => m.MAGAZZINO_ROUTES) }, { - path: 'eventi', - loadChildren: () => import('./eventi/eventi.routes').then((m) => m.EVENTI_ROUTES) - }, - { - path: 'moderazione', - loadChildren: () => import('./moderazione/moderazione.routes').then((m) => m.MODERAZIONE_ROUTES) + path: 'tassonomie', + loadChildren: () => import('./tassonomie/tassonomie.routes').then((m) => m.TASSONOMIE_ROUTES) } ]; diff --git a/scouthub-magazzino-fe/src/app/app.spec.ts b/scouthub-magazzino-fe/src/app/app.spec.ts index 75753d6..8a43fd2 100644 --- a/scouthub-magazzino-fe/src/app/app.spec.ts +++ b/scouthub-magazzino-fe/src/app/app.spec.ts @@ -1,10 +1,24 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import Keycloak from 'keycloak-js'; +import { KEYCLOAK_EVENT_SIGNAL, KeycloakEvent, KeycloakEventType } from 'keycloak-angular'; + import { App } from './app'; +import { routes } from './app.routes'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], + providers: [ + provideRouter(routes), + { provide: Keycloak, useValue: { authenticated: false, tokenParsed: {} } }, + { + provide: KEYCLOAK_EVENT_SIGNAL, + useValue: signal({ type: KeycloakEventType.Ready, args: true }) + } + ] }).compileComponents(); }); diff --git a/scouthub-magazzino-fe/src/app/app.ts b/scouthub-magazzino-fe/src/app/app.ts index ce330e2..3691f21 100644 --- a/scouthub-magazzino-fe/src/app/app.ts +++ b/scouthub-magazzino-fe/src/app/app.ts @@ -1,9 +1,11 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { Header } from './shared/header/header'; + @Component({ selector: 'app-root', - imports: [RouterOutlet], + imports: [RouterOutlet, Header], templateUrl: './app.html', styleUrl: './app.css' }) diff --git a/scouthub-magazzino-fe/src/app/catalogo/autocomplete-api.service.ts b/scouthub-magazzino-fe/src/app/catalogo/autocomplete-api.service.ts new file mode 100644 index 0000000..7da5d1b --- /dev/null +++ b/scouthub-magazzino-fe/src/app/catalogo/autocomplete-api.service.ts @@ -0,0 +1,37 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../environments/environment'; + +const JSON_STRING_HEADERS = new HttpHeaders({ 'Content-Type': 'application/json' }); + +export type GruppoAutocomplete = 'tipoEvento' | 'materiale'; + +export interface AutocompleteObject { + id: string; + nome: string; + gruppo: GruppoAutocomplete; +} + +export interface AutocompleteGroup { + label: string; + gruppo: GruppoAutocomplete; + objectsList: AutocompleteObject[]; +} + +@Injectable({ providedIn: 'root' }) +export class AutocompleteApiService { + private readonly http = inject(HttpClient); + + search(keyword: string): Observable { + // Il backend si aspetta come body una stringa JSON nuda (es. "corda"), non un oggetto: + // va serializzata esplicitamente per evitare che HttpClient la invii come text/plain non + // incapsulata (stesso pattern di scouthub-attivita-fe). + return this.http.post( + `${environment.magazzinoApiBaseUrl}/autocomplete/search`, + JSON.stringify(keyword), + { headers: JSON_STRING_HEADERS } + ); + } +} diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.css b/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.css index e8669a8..88f3790 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.css +++ b/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.css @@ -1,24 +1,95 @@ .catalogo-liste-modello__header { display: flex; - align-items: center; + align-items: baseline; justify-content: space-between; - gap: var(--space-4); - margin-bottom: 0; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.catalogo-liste-modello__title { + font-size: 30px; + margin: 0; + font-weight: 700; +} + +.catalogo-liste-modello__search-link { + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + background: var(--color-surface); + border: 1px solid var(--color-divider); + border-radius: 10px; + padding: 10px 16px; + color: var(--color-text); + opacity: 0.7; + min-width: 220px; + text-decoration: none; } .catalogo-liste-modello__error { color: var(--color-accent-800); } -.tipo-evento-gruppo { - margin-bottom: var(--space-8); +.catalogo-liste-modello__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; +} + +.lista-modello-card { + cursor: pointer; + padding: 18px; + gap: 10px; + border-radius: 14px; +} + +.lista-modello-card:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.lista-modello-card__altre { + opacity: 0.7; + font-style: italic; +} + +.lista-modello-card__chip { + align-self: flex-start; + padding: 4px 10px; + border-radius: 999px; + background: var(--color-header); + border: 1px solid var(--color-divider); + font-size: 12px; + font-weight: 600; } .lista-modello-card__voci { margin: 0; - padding-left: 1.1rem; + padding-left: 0; + list-style: none; font-size: 13px; opacity: 0.8; + display: flex; + flex-direction: column; + gap: 4px; +} + +.lista-modello-card__voci li { + display: flex; + align-items: center; + gap: 6px; +} + +.lista-modello-card__finta-checkbox { + font-size: 12px; + line-height: 1; +} + +.lista-modello-card__voce-testo--spuntata { + text-decoration: line-through; + opacity: 0.6; } .lista-modello-card__avviso { diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.html b/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.html index a3ae439..fd87c5e 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.html +++ b/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.html @@ -1,53 +1,84 @@
-

Liste modello per tipo evento

- Catalogo materiali +

Liste materiali pubblicate

+ + 🔍Cerca lista... +
-

Liste standard, pronte da usare come base per una lista della tua organizzazione.

@if (loading()) {

Caricamento liste modello…

} @else if (loadError(); as message) { - } @else if (gruppi().length === 0) { + } @else if (liste().length === 0) {

Nessuna lista modello disponibile.

} @else { - @for (gruppo of gruppi(); track gruppo.tipoEvento.id) { -
-

{{ gruppo.tipoEvento.nome }}

- -
- @for (lista of gruppo.liste; track lista.id) { -
-
{{ lista.nome }}
-
    - @for (voce of lista.voci; track voce.materialeId) { -
  • {{ voce.nome }} — {{ voce.quantita }} {{ voce.unitaMisura }}
  • - } -
- - @if (!isAuthenticated) { -

Accedi per usare questa lista come base.

+
+ @for (lista of liste(); track lista.id) { +
+
{{ lista.nome }}
+ @if (lista.tipoEventoNome) { + {{ lista.tipoEventoNome }} + } +
    + @for (item of lista.vociPreview; track item.tipo === 'materiale' ? item.materialeId : item.id) { + @if (item.tipo === 'materiale') { +
  • + {{ + isVoceSpuntata(lista.id, item.materialeId) ? '☑️' : '⬜' + }} + + {{ item.nome }} — {{ item.quantita }} {{ item.unitaMisura }} + +
  • + } @else { +
  • + {{ + isSottoListaSpuntata(lista.id, item) ? '☑️' : '⬜' + }} + + {{ item.nome }} + +
  • } + } + @if (lista.vociRestanti > 0) { +
  • + +{{ lista.vociRestanti }} altr{{ lista.vociRestanti === 1 ? 'a' : 'e' }} voc{{ + lista.vociRestanti === 1 ? 'e' : 'i' + }} +
  • + } +
- @if (creazioneErroreId() === lista.id) { - - } + @if (!isAuthenticated) { +

Accedi per usare questa lista come base.

+ } - -
+ @if (creazioneErroreId() === lista.id) { + + } + + @if (isAuthenticated) { + }
-
- } + } + }
diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.spec.ts b/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.spec.ts index b444ed8..c0a308c 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.spec.ts +++ b/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.spec.ts @@ -3,8 +3,7 @@ import { provideRouter, Router } from '@angular/router'; import { of, throwError } from 'rxjs'; import Keycloak from 'keycloak-js'; -import { Lista, ListeApiService } from '../liste-api.service'; -import { ListaModello, ListeModelloApiService } from '../liste-modello-api.service'; +import { Lista, ListeApiService } from '../../liste/liste-api.service'; import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service'; import { CatalogoListeModello } from './catalogo-liste-modello'; @@ -12,28 +11,52 @@ describe('CatalogoListeModello', () => { let component: CatalogoListeModello; let fixture: ComponentFixture; let tipiEventoApi: { getTipiEvento: ReturnType }; - let listeModelloApi: { getListeModello: ReturnType }; - let listeApi: { creaListaDaModello: ReturnType }; + let listeApi: { getListePubbliche: ReturnType; creaListaDaFork: ReturnType }; let keycloak: { authenticated: boolean | undefined; login: ReturnType }; let router: Router; const tipiEvento: TipoEvento[] = [ - { id: 'te-1', nome: 'Uscita' }, - { id: 'te-2', nome: 'Campo estivo' } + { id: 'te-1', nome: 'Uscita', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' }, + { id: 'te-2', nome: 'Campo estivo', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' } ]; - const listeModello: ListaModello[] = [ + const listeModello: Lista[] = [ { id: 'lm-1', nome: 'Uscita di un giorno', + orgId: null, + stato: 'pubblico', + statoModerazione: 'approvato', tipoEventoId: 'te-1', - voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }] + parentId: null, + creataIl: '2026-01-01', + creataDaMe: false, + voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }], + sottoListe: [ + { + id: 'lm-kit-ps', + nome: 'Kit di pronto soccorso', + voci: [{ materialeId: 'mat-ps-1', nome: 'Garze', unitaMisura: 'pz', quantita: 5 }] + } + ] }, { id: 'lm-2', nome: 'Campo estivo standard', + orgId: null, + stato: 'pubblico', + statoModerazione: 'approvato', tipoEventoId: 'te-2', - voci: [{ materialeId: 'mat-2', nome: 'Telo cerato', unitaMisura: 'pz', quantita: 5 }] + parentId: null, + creataIl: '2026-01-01', + creataDaMe: false, + voci: Array.from({ length: 7 }, (_, i) => ({ + materialeId: `mat-${i + 2}`, + nome: `Materiale ${i + 2}`, + unitaMisura: 'pz', + quantita: 1 + })), + sottoListe: [] } ]; @@ -45,7 +68,6 @@ describe('CatalogoListeModello', () => { providers: [ provideRouter([]), { provide: TipiEventoApiService, useValue: tipiEventoApi }, - { provide: ListeModelloApiService, useValue: listeModelloApi }, { provide: ListeApiService, useValue: listeApi }, { provide: Keycloak, useValue: keycloak } ] @@ -53,6 +75,7 @@ describe('CatalogoListeModello', () => { router = TestBed.inject(Router); vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + vi.spyOn(router, 'navigate').mockResolvedValue(true); fixture = TestBed.createComponent(CatalogoListeModello); component = fixture.componentInstance; @@ -62,12 +85,12 @@ describe('CatalogoListeModello', () => { } beforeEach(() => { + sessionStorage.clear(); tipiEventoApi = { getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)) }; - listeModelloApi = { getListeModello: vi.fn().mockReturnValue(of(listeModello)) }; - listeApi = { creaListaDaModello: vi.fn() }; + listeApi = { getListePubbliche: vi.fn().mockReturnValue(of(listeModello)), creaListaDaFork: vi.fn() }; }); - it('mostra le liste modello raggruppate per tipo evento con voci e quantità', async () => { + it('mostra tutte le liste modello insieme, con il tipo evento come chip nella card', async () => { keycloak = { authenticated: false, login: vi.fn() }; await setup(); fixture.detectChanges(); @@ -75,13 +98,108 @@ describe('CatalogoListeModello', () => { expect(component.loading()).toBe(false); const compiled = fixture.nativeElement as HTMLElement; - const gruppi = compiled.querySelectorAll('.tipo-evento-gruppo'); - expect(gruppi.length).toBe(2); - expect(gruppi[0].querySelector('h2')?.textContent).toContain('Uscita'); - expect(gruppi[0].textContent).toContain('Corda — 2 pz'); - const cards = compiled.querySelectorAll('.lista-modello-card'); expect(cards.length).toBe(2); + expect(cards[0].querySelector('.lista-modello-card__chip')?.textContent).toContain('Uscita'); + expect(cards[0].textContent).toContain('Corda — 2 pz'); + expect(cards[1].querySelector('.lista-modello-card__chip')?.textContent).toContain('Campo estivo'); + }); + + it('mostra solo le prime 5 voci di una lista con più voci, con il conteggio delle restanti', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const cards = compiled.querySelectorAll('.lista-modello-card'); + const voci = cards[1].querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)'); + expect(voci.length).toBe(5); + expect(cards[1].querySelector('.lista-modello-card__altre')?.textContent).toContain('+2 altre voci'); + }); + + it('il click sulla card naviga al dettaglio della lista modello', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const card = compiled.querySelector('.lista-modello-card') as HTMLElement; + card.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/lista-modello', 'lm-1']); + }); + + it('le voci non sono interattive: il click su una voce apre comunque il dettaglio', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.lista-modello-card__voci input')).toBeNull(); + + const voce = compiled.querySelector('.lista-modello-card__voci li') as HTMLElement; + voce.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/lista-modello', 'lm-1']); + }); + + it('mostra una finta checkbox per ogni voce, spuntata e barrata se già segnata (es. dal dettaglio)', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + sessionStorage.setItem('scouthub-magazzino:lista-modello-voci-spuntate:lm-1', JSON.stringify(['mat-1'])); + await setup(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const voce = compiled.querySelector('.lista-modello-card__voci li') as HTMLElement; + expect(voce.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('☑️'); + expect(voce.querySelector('.lista-modello-card__voce-testo--spuntata')).not.toBeNull(); + }); + + it('mostra una finta checkbox vuota per una voce non segnata', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const voce = compiled.querySelector('.lista-modello-card__voci li') as HTMLElement; + expect(voce.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('⬜'); + expect(voce.querySelector('.lista-modello-card__voce-testo--spuntata')).toBeNull(); + }); + + it('mostra una sotto-lista allegata come una voce in più, con solo il titolo (non le sue voci)', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const cards = compiled.querySelectorAll('.lista-modello-card'); + const voci = cards[0].querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)'); + expect(voci.length).toBe(2); + expect(cards[0].textContent).toContain('Kit di pronto soccorso'); + expect(cards[0].textContent).not.toContain('Garze'); + }); + + it('mostra la sotto-lista come spuntata solo se tutte le sue voci sono spuntate', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + sessionStorage.setItem('scouthub-magazzino:lista-modello-voci-spuntate:lm-1', JSON.stringify(['mat-ps-1'])); + await setup(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const voci = compiled.querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)'); + const vocesottoLista = Array.from(voci).find((li) => li.textContent?.includes('Kit di pronto soccorso')); + expect(vocesottoLista?.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('☑️'); + }); + + it('mostra la sotto-lista come non spuntata se manca almeno una delle sue voci', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const voci = compiled.querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)'); + const vocesottoLista = Array.from(voci).find((li) => li.textContent?.includes('Kit di pronto soccorso')); + expect(vocesottoLista?.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('⬜'); }); it('mostra un messaggio di errore se il caricamento fallisce', async () => { @@ -108,21 +226,18 @@ describe('CatalogoListeModello', () => { expect(avvisi[0].textContent).toContain('Accedi per usare questa lista come base'); }); - it('avvia il login e non chiama l\'API al click sul pulsante', async () => { + it('non mostra il pulsante "Usa come base"', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.lista-modello-card button')).toBeNull(); + }); + + it('avvia il login e non chiama l\'API se invocato comunque', async () => { await component.usaComeBase('lm-1'); expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: window.location.href }); - expect(listeApi.creaListaDaModello).not.toHaveBeenCalled(); + expect(listeApi.creaListaDaFork).not.toHaveBeenCalled(); expect(router.navigateByUrl).not.toHaveBeenCalled(); }); - - it('il click sul pulsante nel DOM avvia il login', () => { - const compiled = fixture.nativeElement as HTMLElement; - const button = compiled.querySelector('.lista-modello-card button') as HTMLButtonElement; - button.click(); - - expect(keycloak.login).toHaveBeenCalled(); - }); }); describe('pulsante "Usa come base" — utente autenticato', () => { @@ -132,25 +247,60 @@ describe('CatalogoListeModello', () => { fixture.detectChanges(); }); + it('il click sul pulsante "Usa come base" non apre anche il dettaglio della card', () => { + listeApi.creaListaDaFork.mockReturnValue( + of({ + id: 'lp-1', + nome: 'x', + orgId: null, + stato: 'bozza', + statoModerazione: null, + tipoEventoId: null, + parentId: 'lm-1', + creataIl: '2026-01-01', + voci: [], + sottoListe: [] + }) + ); + + const compiled = fixture.nativeElement as HTMLElement; + const button = compiled.querySelector('.lista-modello-card button') as HTMLButtonElement; + button.click(); + + expect(router.navigate).not.toHaveBeenCalled(); + }); + it('non mostra l\'avviso di accesso', () => { const compiled = fixture.nativeElement as HTMLElement; expect(compiled.querySelector('.lista-modello-card__avviso')).toBeNull(); }); - it('chiama POST /liste/da-modello/:id e reindirizza alla lista privata creata', async () => { - const listaCreata: Lista = { id: 'lista-privata-1', nome: 'Uscita di un giorno', orgId: 'org-1', creataIl: '2026-01-01', voci: [] }; - listeApi.creaListaDaModello.mockReturnValue(of(listaCreata)); + it('chiama POST /liste/da-fork/:id e reindirizza alla lista privata creata', async () => { + const listaCreata: Lista = { + id: 'lista-privata-1', + nome: 'Uscita di un giorno', + orgId: null, + stato: 'bozza', + statoModerazione: null, + tipoEventoId: null, + parentId: 'lm-1', + creataDaMe: true, + creataIl: '2026-01-01', + voci: [], + sottoListe: [] + }; + listeApi.creaListaDaFork.mockReturnValue(of(listaCreata)); await component.usaComeBase('lm-1'); expect(keycloak.login).not.toHaveBeenCalled(); - expect(listeApi.creaListaDaModello).toHaveBeenCalledWith('lm-1'); + expect(listeApi.creaListaDaFork).toHaveBeenCalledWith('lm-1'); expect(router.navigateByUrl).toHaveBeenCalledWith('/liste/lista-privata-1'); expect(component.creazioneInCorsoId()).toBeNull(); }); it('mostra un messaggio di errore sulla lista interessata se la creazione fallisce', async () => { - listeApi.creaListaDaModello.mockReturnValue(throwError(() => new Error('server error'))); + listeApi.creaListaDaFork.mockReturnValue(throwError(() => new Error('server error'))); await component.usaComeBase('lm-1'); diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.ts b/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.ts index 25bbd65..a0af64b 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.ts +++ b/scouthub-magazzino-fe/src/app/catalogo/catalogo-liste-modello/catalogo-liste-modello.ts @@ -5,13 +5,23 @@ import { Router, RouterLink } from '@angular/router'; import Keycloak from 'keycloak-js'; import { firstValueFrom } from 'rxjs'; -import { ListeApiService } from '../liste-api.service'; -import { ListaModello, ListeModelloApiService } from '../liste-modello-api.service'; +import { Lista, ListaVoce, ListeApiService, SottoLista } from '../../liste/liste-api.service'; import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service'; +import { VoceCheckStorageService } from '../voce-check-storage.service'; -interface GruppoTipoEvento { - tipoEvento: TipoEvento; - liste: ListaModello[]; +const VOCI_PREVIEW_LIMIT = 5; + +// In home una sotto-lista allegata compare come una voce in più tra le altre (non come un +// chip a parte): questo tipo unisce voci materiale e sotto-liste in un'unica sequenza per la +// preview troncata a VOCI_PREVIEW_LIMIT. +type ItemAnteprima = + | ({ tipo: 'materiale' } & ListaVoce) + | ({ tipo: 'sottoLista' } & SottoLista); + +interface ListaModelloVista extends Lista { + tipoEventoNome: string; + vociPreview: ItemAnteprima[]; + vociRestanti: number; } @Component({ @@ -22,26 +32,32 @@ interface GruppoTipoEvento { }) export class CatalogoListeModello implements OnInit { private readonly tipiEventoApi = inject(TipiEventoApiService); - private readonly listeModelloApi = inject(ListeModelloApiService); private readonly listeApi = inject(ListeApiService); private readonly keycloak = inject(Keycloak); private readonly router = inject(Router); + private readonly voceCheckStorage = inject(VoceCheckStorageService); readonly loading = signal(true); readonly loadError = signal(null); readonly tipiEvento = signal([]); - readonly listeModello = signal([]); + readonly listeModello = signal([]); readonly creazioneInCorsoId = signal(null); readonly creazioneErroreId = signal(null); - readonly gruppi = computed(() => { - const liste = this.listeModello(); - return this.tipiEvento() - .map((tipoEvento) => ({ - tipoEvento, - liste: liste.filter((lista) => lista.tipoEventoId === tipoEvento.id) - })) - .filter((gruppo) => gruppo.liste.length > 0); + readonly liste = computed(() => { + const nomiPerTipoEvento = new Map(this.tipiEvento().map((tipoEvento) => [tipoEvento.id, tipoEvento.nome])); + return this.listeModello().map((lista) => { + const items: ItemAnteprima[] = [ + ...lista.voci.map((voce): ItemAnteprima => ({ tipo: 'materiale', ...voce })), + ...lista.sottoListe.map((sottoLista): ItemAnteprima => ({ tipo: 'sottoLista', ...sottoLista })) + ]; + return { + ...lista, + tipoEventoNome: (lista.tipoEventoId && nomiPerTipoEvento.get(lista.tipoEventoId)) ?? '', + vociPreview: items.slice(0, VOCI_PREVIEW_LIMIT), + vociRestanti: Math.max(0, items.length - VOCI_PREVIEW_LIMIT) + }; + }); }); get isAuthenticated(): boolean { @@ -52,6 +68,40 @@ export class CatalogoListeModello implements OnInit { await this.carica(); } + onCardClick(event: Event, listaModelloId: string): void { + // Le voci nella home non sono più interattive: l'unico elemento cliccabile dentro la + // card, oltre alla card stessa, è il pulsante "Usa come base", che non deve anche aprire + // il dettaglio. + const target = event.target as HTMLElement; + if (target.closest('button')) { + return; + } + this.apriDettaglio(listaModelloId); + } + + apriDettaglio(listaModelloId: string): void { + this.router.navigate(['/lista-modello', listaModelloId]); + } + + // Le voci in home sono di sola lettura (il click apre il dettaglio): la spunta è solo una + // finta checkbox che riflette lo stato salvato dal dettaglio, non è cliccabile qui. + isVoceSpuntata(listaModelloId: string, materialeId: string): boolean { + return this.voceCheckStorage.isSpuntata(listaModelloId, materialeId); + } + + // Una sotto-lista risulta spuntata in home solo se lo sono tutte le sue voci materiale + // (spuntate nel dettaglio, nello stesso namespace della lista padre). + isSottoListaSpuntata(listaModelloId: string, sottoLista: SottoLista): boolean { + return ( + sottoLista.voci.length > 0 && + sottoLista.voci.every((voce) => this.voceCheckStorage.isSpuntata(listaModelloId, voce.materialeId)) + ); + } + + onUsaComeBase(listaModelloId: string): void { + void this.usaComeBase(listaModelloId); + } + async usaComeBase(listaModelloId: string): Promise { if (!this.isAuthenticated) { this.keycloak.login({ redirectUri: window.location.href }); @@ -62,7 +112,7 @@ export class CatalogoListeModello implements OnInit { this.creazioneInCorsoId.set(listaModelloId); try { - const lista = await firstValueFrom(this.listeApi.creaListaDaModello(listaModelloId)); + const lista = await firstValueFrom(this.listeApi.creaListaDaFork(listaModelloId)); this.creazioneInCorsoId.set(null); await this.router.navigateByUrl(`/liste/${lista.id}`); } catch { @@ -77,7 +127,7 @@ export class CatalogoListeModello implements OnInit { try { const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEvento()); - const listeModello = await firstValueFrom(this.listeModelloApi.getListeModello()); + const listeModello = await firstValueFrom(this.listeApi.getListePubbliche()); this.tipiEvento.set(tipiEvento); this.listeModello.set(listeModello); } catch { diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.css b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.css index 7e1a23e..ca0d84b 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.css +++ b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.css @@ -6,12 +6,6 @@ margin-bottom: var(--space-4); } -.catalogo-materiali__area-privata { - margin-bottom: var(--space-5); - padding-left: 0; - padding-right: 0; -} - .catalogo-materiali__filtri { flex-wrap: wrap; margin-bottom: var(--space-5); diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.html b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.html index d98fcef..72716cc 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.html +++ b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.html @@ -4,21 +4,9 @@

Catalogo materiali

Materiali condivisi tra tutte le organizzazioni. Consultabile senza login.

- Liste modello per evento + Liste modello per evento - @if (isAuthenticated) { - - } - @if (loading()) {

Caricamento catalogo…

} @else if (loadError(); as message) { diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.spec.ts b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.spec.ts index 4a86590..993a8ce 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.spec.ts +++ b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { of, throwError } from 'rxjs'; -import Keycloak from 'keycloak-js'; import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service'; import { CatalogoMateriali } from './catalogo-materiali'; @@ -10,10 +9,6 @@ describe('CatalogoMateriali', () => { let component: CatalogoMateriali; let fixture: ComponentFixture; let materialiApi: { getMateriali: ReturnType }; - let keycloak: { - authenticated: boolean | undefined; - tokenParsed?: { realm_access?: { roles?: string[] } }; - }; const materiali: MaterialePubblico[] = [ { id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' }, @@ -24,11 +19,7 @@ describe('CatalogoMateriali', () => { async function setup(): Promise { await TestBed.configureTestingModule({ imports: [CatalogoMateriali], - providers: [ - provideRouter([]), - { provide: MaterialiApiService, useValue: materialiApi }, - { provide: Keycloak, useValue: keycloak } - ] + providers: [provideRouter([]), { provide: MaterialiApiService, useValue: materialiApi }] }).compileComponents(); fixture = TestBed.createComponent(CatalogoMateriali); @@ -38,10 +29,6 @@ describe('CatalogoMateriali', () => { fixture.detectChanges(); } - beforeEach(() => { - keycloak = { authenticated: false }; - }); - it('mostra tutti i materiali del catalogo dopo il caricamento', async () => { materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) }; @@ -89,39 +76,4 @@ describe('CatalogoMateriali', () => { expect(component.loading()).toBe(false); expect(component.loadError()).toBe('Impossibile caricare il catalogo dei materiali. Riprova più tardi.'); }); - - describe('area privata e voce di menu "Moderazione catalogo"', () => { - beforeEach(() => { - materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) }; - }); - - it('non mostra la nav dell\'area privata per un utente non autenticato', async () => { - keycloak = { authenticated: false }; - await setup(); - - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.catalogo-materiali__area-privata')).toBeNull(); - }); - - it('mostra la nav dell\'area privata ma non "Moderazione catalogo" per un utente senza il ruolo moderatore', async () => { - keycloak = { authenticated: true, tokenParsed: { realm_access: { roles: ['censito'] } } }; - await setup(); - - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.catalogo-materiali__area-privata')).toBeTruthy(); - const link = Array.from(compiled.querySelectorAll('.catalogo-materiali__area-privata a')).find((a) => - a.textContent?.includes('Moderazione catalogo') - ); - expect(link).toBeUndefined(); - }); - - it('mostra "Moderazione catalogo" per un utente con il ruolo moderatore', async () => { - keycloak = { authenticated: true, tokenParsed: { realm_access: { roles: ['moderatore'] } } }; - await setup(); - - const compiled = fixture.nativeElement as HTMLElement; - const link = compiled.querySelector('.catalogo-materiali__area-privata a[href="/moderazione"]'); - expect(link).toBeTruthy(); - }); - }); }); diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.ts b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.ts index 7bc3833..7285b31 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.ts +++ b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.ts @@ -2,10 +2,8 @@ import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { RouterLink } from '@angular/router'; -import Keycloak from 'keycloak-js'; import { firstValueFrom } from 'rxjs'; -import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles'; import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service'; @Component({ @@ -16,7 +14,6 @@ import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service }) export class CatalogoMateriali implements OnInit { private readonly materialiApi = inject(MaterialiApiService); - private readonly keycloak = inject(Keycloak); readonly loading = signal(true); readonly loadError = signal(null); @@ -34,14 +31,6 @@ export class CatalogoMateriali implements OnInit { return categoria ? materiali.filter((materiale) => materiale.categoria === categoria) : materiali; }); - get isAuthenticated(): boolean { - return this.keycloak.authenticated ?? false; - } - - get isModeratore(): boolean { - return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE); - } - async ngOnInit(): Promise { await this.caricaMateriali(); } diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo.routes.ts b/scouthub-magazzino-fe/src/app/catalogo/catalogo.routes.ts index b5020d9..01c3e3f 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo.routes.ts +++ b/scouthub-magazzino-fe/src/app/catalogo/catalogo.routes.ts @@ -1,24 +1,27 @@ import { Routes } from '@angular/router'; -import { requireAuthGuard } from '../core/require-auth.guard'; - export const CATALOGO_ROUTES: Routes = [ - { - // Pubblica: il catalogo del materiale è consultabile anche da chi non è autenticato. - path: '', - loadComponent: () => import('./catalogo-materiali/catalogo-materiali').then((m) => m.CatalogoMateriali) - }, { // Pubblica: le liste modello sono consultabili da chi non è autenticato; // solo la creazione della lista privata a partire dal modello richiede il login. - path: 'liste-modello', + path: '', loadComponent: () => import('./catalogo-liste-modello/catalogo-liste-modello').then((m) => m.CatalogoListeModello) }, { - // Privata: proporre un materiale richiede un'org sul token (POST /materiali/proposte). - path: 'proponi-materiale', - loadComponent: () => import('./proponi-materiale/proponi-materiale').then((m) => m.ProponiMateriale), - canActivate: [requireAuthGuard] + // Pubblica: il catalogo del materiale è consultabile anche da chi non è autenticato. + path: 'catalogo-materiali', + loadComponent: () => import('./catalogo-materiali/catalogo-materiali').then((m) => m.CatalogoMateriali) + }, + { + // Pubblica: la ricerca delle liste modello è consultabile anche da chi non è autenticato. + path: 'cerca-lista', + loadComponent: () => import('./cerca-lista/cerca-lista').then((m) => m.CercaLista) + }, + { + // Pubblica: il dettaglio di una lista modello è consultabile anche da chi non è autenticato. + path: 'lista-modello/:id', + loadComponent: () => + import('./lista-modello-dettaglio/lista-modello-dettaglio').then((m) => m.ListaModelloDettaglio) } ]; diff --git a/scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.css b/scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.css new file mode 100644 index 0000000..67360e0 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.css @@ -0,0 +1,155 @@ +.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-divider); + 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-divider); + border-radius: 10px; + box-shadow: var(--shadow-md); + 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: color-mix(in srgb, var(--color-text) 55%, transparent); +} + +.suggestion-item { + padding: 10px 16px; + cursor: pointer; + font-size: 15px; +} + +.suggestion-item:hover { + background: var(--color-accent-100); +} + +.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-accent-200); + border: 1px solid var(--color-accent-300); + font-size: 14px; + font-weight: 600; + color: var(--color-accent-700); +} + +.filter-remove { + cursor: pointer; + font-weight: 700; +} + +.cerca-lista__error { + color: var(--color-accent-800); +} + +.cerca-lista__results-label { + font-weight: 700; + opacity: 0.7; + font-size: 14px; + margin-bottom: var(--space-3); +} + +.lista-modello-card { + cursor: pointer; + padding: 18px; + gap: 10px; + border-radius: 14px; +} + +.lista-modello-card:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.lista-modello-card__chip { + align-self: flex-start; + padding: 4px 10px; + border-radius: 999px; + background: var(--color-header); + border: 1px solid var(--color-divider); + font-size: 12px; + font-weight: 600; +} + +.lista-modello-card__voci { + margin: 0; + padding-left: 0; + list-style: none; + font-size: 13px; + opacity: 0.8; + display: flex; + flex-direction: column; + gap: 4px; +} + +.lista-modello-card__voci li { + display: flex; + align-items: center; + gap: 6px; +} + +.lista-modello-card__finta-checkbox { + font-size: 12px; + line-height: 1; +} + +.lista-modello-card__voce-testo--spuntata { + text-decoration: line-through; + opacity: 0.6; +} + +.lista-modello-card__altre { + opacity: 0.7; + font-style: italic; +} + +.lista-modello-card__avviso { + color: var(--color-text); + opacity: 0.6; + font-size: 12px; +} + +.lista-modello-card__errore { + color: var(--color-accent-800); + font-size: 12px; +} diff --git a/scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.html b/scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.html new file mode 100644 index 0000000..b4d75aa --- /dev/null +++ b/scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.html @@ -0,0 +1,115 @@ +
+

Cerca lista

+ +
+ + @if (showSuggestions()) { +
+ @for (group of suggestionGroups(); track group.label) { +
{{ group.label }}
+ @for (suggestion of group.objectsList; track suggestion.id) { +
+ {{ suggestion.nome }} +
+ } + } +
+ } +
+ +
+ @for (filtro of activeFilters(); track $index) { +
+ {{ filtroLabel(filtro.gruppo) }}: {{ filtro.nome }} + × +
+ } +
+ + @if (loading()) { +

Caricamento liste modello…

+ } @else if (loadError(); as message) { + + } @else { +
{{ resultsLabel() }}
+ + @if (risultati().length === 0) { +

Nessuna lista trovata con questi filtri.

+ } @else { +
+ @for (lista of risultati(); track lista.id) { +
+
{{ lista.nome }}
+ @if (lista.tipoEventoNome) { + {{ lista.tipoEventoNome }} + } +
    + @for (item of lista.vociPreview; track item.tipo === 'materiale' ? item.materialeId : item.id) { + @if (item.tipo === 'materiale') { +
  • + {{ + isVoceSpuntata(lista.id, item.materialeId) ? '☑️' : '⬜' + }} + + {{ item.nome }} — {{ item.quantita }} {{ item.unitaMisura }} + +
  • + } @else { +
  • + {{ + isSottoListaSpuntata(lista.id, item) ? '☑️' : '⬜' + }} + + {{ item.nome }} + +
  • + } + } + @if (lista.vociRestanti > 0) { +
  • + +{{ lista.vociRestanti }} altr{{ lista.vociRestanti === 1 ? 'a' : 'e' }} voc{{ + lista.vociRestanti === 1 ? 'e' : 'i' + }} +
  • + } +
+ + @if (!isAuthenticated) { +

Accedi per usare questa lista come base.

+ } + + @if (creazioneErroreId() === lista.id) { + + } + + @if (isAuthenticated) { + + } +
+ } +
+ } + } +
diff --git a/scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.ts b/scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.ts new file mode 100644 index 0000000..fba063c --- /dev/null +++ b/scouthub-magazzino-fe/src/app/catalogo/cerca-lista/cerca-lista.ts @@ -0,0 +1,231 @@ +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; +import Keycloak from 'keycloak-js'; +import { catchError, debounceTime, distinctUntilChanged, firstValueFrom, of, switchMap } from 'rxjs'; + +import { AutocompleteApiService, AutocompleteGroup } from '../autocomplete-api.service'; +import { Lista, ListaVoce, ListeApiService, SottoLista } from '../../liste/liste-api.service'; +import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service'; +import { VoceCheckStorageService } from '../voce-check-storage.service'; + +const VOCI_PREVIEW_LIMIT = 5; +const AUTOCOMPLETE_DEBOUNCE_MS = 300; + +// Stessa preview usata in home: voci materiale e sotto-liste unite in un'unica sequenza +// troncata a VOCI_PREVIEW_LIMIT, per avere le card identiche tra home e ricerca. +type ItemAnteprima = + | ({ tipo: 'materiale' } & ListaVoce) + | ({ tipo: 'sottoLista' } & SottoLista); + +interface ListaModelloVista extends Lista { + tipoEventoNome: string; + vociPreview: ItemAnteprima[]; + vociRestanti: number; +} + +// Stesso modello di ricerca a filtri combinabili di scouthub-attivita-fe (Search + GruppoFiltro/ +// SearchObjectDto): l'utente digita, sceglie un suggerimento (o preme Invio per un filtro testuale +// libero) e ottiene un "chip" che si combina in AND con gli altri filtri attivi. +type GruppoFiltro = 'testo' | 'tipoEvento' | 'materiale'; + +interface FiltroAttivo { + id: string | null; + nome: string; + gruppo: GruppoFiltro; +} + +@Component({ + selector: 'app-cerca-lista', + imports: [], + templateUrl: './cerca-lista.html', + styleUrl: './cerca-lista.css' +}) +export class CercaLista implements OnInit { + private readonly autocompleteApi = inject(AutocompleteApiService); + private readonly tipiEventoApi = inject(TipiEventoApiService); + private readonly listeApi = inject(ListeApiService); + private readonly keycloak = inject(Keycloak); + private readonly router = inject(Router); + private readonly voceCheckStorage = inject(VoceCheckStorageService); + + readonly loading = signal(true); + readonly loadError = signal(null); + readonly tipiEvento = signal([]); + readonly listeModello = signal([]); + readonly creazioneInCorsoId = signal(null); + readonly creazioneErroreId = signal(null); + + readonly searchQuery = signal(''); + readonly activeFilters = signal([]); + + readonly suggestionGroups = signal([]); + readonly showSuggestions = computed(() => this.suggestionGroups().length > 0); + + private readonly liste = computed(() => { + const nomiPerTipoEvento = new Map(this.tipiEvento().map((tipoEvento) => [tipoEvento.id, tipoEvento.nome])); + return this.listeModello().map((lista) => { + const items: ItemAnteprima[] = [ + ...lista.voci.map((voce): ItemAnteprima => ({ tipo: 'materiale', ...voce })), + ...lista.sottoListe.map((sottoLista): ItemAnteprima => ({ tipo: 'sottoLista', ...sottoLista })) + ]; + return { + ...lista, + tipoEventoNome: (lista.tipoEventoId && nomiPerTipoEvento.get(lista.tipoEventoId)) ?? '', + vociPreview: items.slice(0, VOCI_PREVIEW_LIMIT), + vociRestanti: Math.max(0, items.length - VOCI_PREVIEW_LIMIT) + }; + }); + }); + + readonly risultati = computed(() => { + const filtri = this.activeFilters(); + if (filtri.length === 0) { + return this.liste(); + } + + return this.liste().filter((lista) => filtri.every((filtro) => this.listaMatchFiltro(lista, filtro))); + }); + + readonly resultsLabel = computed(() => { + const n = this.risultati().length; + return `${n} risultat${n === 1 ? 'o' : 'i'}`; + }); + + get isAuthenticated(): boolean { + return this.keycloak.authenticated ?? false; + } + + constructor() { + // Come in scouthub-attivita-fe: aspetta che l'utente smetta di scrivere per + // AUTOCOMPLETE_DEBOUNCE_MS e poi interroga un'unica search di autocomplete lato backend + // (non liste già precaricate in memoria). + toObservable(this.searchQuery) + .pipe( + debounceTime(AUTOCOMPLETE_DEBOUNCE_MS), + distinctUntilChanged(), + switchMap((query) => { + const testo = query.trim(); + if (!testo) { + return of([]); + } + return this.autocompleteApi.search(testo).pipe(catchError(() => of([]))); + }), + takeUntilDestroyed() + ) + .subscribe((groups) => this.suggestionGroups.set(groups)); + } + + async ngOnInit(): Promise { + await this.carica(); + } + + onQueryChange(event: Event): void { + this.searchQuery.set((event.target as HTMLInputElement).value); + } + + onQueryKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter' && this.searchQuery().trim()) { + this.addFiltro({ id: null, nome: this.searchQuery().trim(), gruppo: 'testo' }); + } + } + + selectSuggestion(gruppo: GruppoFiltro, suggestion: { id: string; nome: string }): void { + this.addFiltro({ id: suggestion.id, nome: suggestion.nome, gruppo }); + } + + removeFiltro(index: number): void { + this.activeFilters.update((filtri) => filtri.filter((_, i) => i !== index)); + } + + filtroLabel(gruppo: GruppoFiltro): string { + const etichette: Record = { + testo: 'Testo', + tipoEvento: 'Tipo evento', + materiale: 'Materiale' + }; + return etichette[gruppo]; + } + + onCardClick(event: Event, listaModelloId: string): void { + // Come in home: l'unico elemento cliccabile dentro la card, oltre alla card stessa, è il + // pulsante "Usa come base", che non deve anche aprire il dettaglio. + const target = event.target as HTMLElement; + if (target.closest('button')) { + return; + } + this.apriDettaglio(listaModelloId); + } + + apriDettaglio(listaModelloId: string): void { + this.router.navigate(['/lista-modello', listaModelloId]); + } + + isVoceSpuntata(listaModelloId: string, materialeId: string): boolean { + return this.voceCheckStorage.isSpuntata(listaModelloId, materialeId); + } + + isSottoListaSpuntata(listaModelloId: string, sottoLista: SottoLista): boolean { + return ( + sottoLista.voci.length > 0 && + sottoLista.voci.every((voce) => this.voceCheckStorage.isSpuntata(listaModelloId, voce.materialeId)) + ); + } + + onUsaComeBase(listaModelloId: string): void { + void this.usaComeBase(listaModelloId); + } + + async usaComeBase(listaModelloId: string): Promise { + if (!this.isAuthenticated) { + this.keycloak.login({ redirectUri: window.location.href }); + return; + } + + this.creazioneErroreId.set(null); + this.creazioneInCorsoId.set(listaModelloId); + + try { + const lista = await firstValueFrom(this.listeApi.creaListaDaFork(listaModelloId)); + this.creazioneInCorsoId.set(null); + await this.router.navigateByUrl(`/liste/${lista.id}`); + } catch { + this.creazioneInCorsoId.set(null); + this.creazioneErroreId.set(listaModelloId); + } + } + + private addFiltro(filtro: FiltroAttivo): void { + this.activeFilters.update((filtri) => [...filtri, filtro]); + this.searchQuery.set(''); + } + + private listaMatchFiltro(lista: ListaModelloVista, filtro: FiltroAttivo): boolean { + switch (filtro.gruppo) { + case 'tipoEvento': + return lista.tipoEventoId === filtro.id; + case 'materiale': + return lista.voci.some((voce) => voce.materialeId === filtro.id); + case 'testo': { + const testo = filtro.nome.toLowerCase(); + return lista.nome.toLowerCase().includes(testo) || lista.voci.some((voce) => voce.nome.toLowerCase().includes(testo)); + } + } + } + + private async carica(): Promise { + this.loading.set(true); + this.loadError.set(null); + + try { + const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEvento()); + const listeModello = await firstValueFrom(this.listeApi.getListePubbliche()); + this.tipiEvento.set(tipiEvento); + this.listeModello.set(listeModello); + } catch { + this.loadError.set('Impossibile caricare le liste modello. Riprova più tardi.'); + } finally { + this.loading.set(false); + } + } +} diff --git a/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.css b/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.css new file mode 100644 index 0000000..bebf147 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.css @@ -0,0 +1,69 @@ +.lista-modello-dettaglio { + max-width: 640px; +} + +.lista-modello-dettaglio__titolo { + font-size: 32px; + margin: 0 0 var(--space-3); + font-weight: 700; +} + +.lista-modello-dettaglio__chip { + display: inline-flex; + padding: 4px 10px; + border-radius: 999px; + background: var(--color-accent-100); + color: var(--color-accent-800); + font-size: 12px; + font-weight: 600; + margin-bottom: var(--space-5); +} + +.lista-modello-dettaglio__voci { + margin: 0 0 var(--space-5); + padding-left: 0; + list-style: none; + font-size: 15px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.lista-modello-dettaglio__voce { + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; +} + +.lista-modello-dettaglio__voce-testo--spuntata { + text-decoration: line-through; + opacity: 0.6; +} + +.lista-modello-dettaglio__sotto-lista { + margin: 0 0 var(--space-5); +} + +.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce { + margin-bottom: var(--space-2); +} + +.lista-modello-dettaglio__sotto-lista-titolo { + font-size: 17px; + font-weight: 600; +} + +.lista-modello-dettaglio__voci--indentate { + padding-left: 28px; +} + +.lista-modello-dettaglio__avviso { + opacity: 0.6; + font-size: 13px; +} + +.lista-modello-dettaglio__errore { + color: var(--color-accent-800); + font-size: 13px; +} diff --git a/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.html b/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.html new file mode 100644 index 0000000..74cb000 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.html @@ -0,0 +1,80 @@ +@if (lista(); as l) { +
+

{{ l.nome }}

+ + @if (tipoEventoNome()) { + {{ tipoEventoNome() }} + } + +
    + @for (voce of l.voci; track voce.materialeId) { +
  • + +
  • + } +
+ + @for (sottoLista of l.sottoListe; track sottoLista.id) { +
+ +
    + @for (voce of sottoLista.voci; track voce.materialeId) { +
  • + +
  • + } +
+
+ } + + @if (!isAuthenticated) { +

Accedi per usare questa lista come base.

+ } + + @if (creazioneErrore()) { + + } + + @if (isAuthenticated) { + + } +
+} + +@if (!lista() && !loading()) { +
Lista non trovata.
+} diff --git a/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.spec.ts b/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.spec.ts new file mode 100644 index 0000000..b372756 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.spec.ts @@ -0,0 +1,212 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import Keycloak from 'keycloak-js'; + +import { Lista, ListeApiService } from '../../liste/liste-api.service'; +import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service'; +import { ListaModelloDettaglio } from './lista-modello-dettaglio'; + +describe('ListaModelloDettaglio', () => { + let component: ListaModelloDettaglio; + let fixture: ComponentFixture; + let tipiEventoApi: { getTipiEvento: ReturnType }; + let listeApi: { getListaPubblica: ReturnType; creaListaDaFork: ReturnType }; + let keycloak: { authenticated: boolean | undefined; login: ReturnType }; + let router: Router; + + const tipiEvento: TipoEvento[] = [ + { id: 'te-1', nome: 'Uscita', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' } + ]; + + async function flushUntil(predicate: () => boolean, maxTentativi = 20): Promise { + for (let tentativi = 0; !predicate() && tentativi < maxTentativi; tentativi++) { + await Promise.resolve(); + fixture.detectChanges(); + } + } + + const lista: Lista = { + id: 'lm-1', + nome: 'Uscita di un giorno', + orgId: null, + stato: 'pubblico', + statoModerazione: 'approvato', + tipoEventoId: 'te-1', + parentId: null, + creataIl: '2026-01-01', + creataDaMe: false, + voci: [ + { materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }, + { materialeId: 'mat-2', nome: 'Torcia', unitaMisura: 'pz', quantita: 1 } + ], + sottoListe: [ + { + id: 'lm-kit-ps', + nome: 'Kit di pronto soccorso', + voci: [ + { materialeId: 'mat-3', nome: 'Garze', unitaMisura: 'pz', quantita: 5 }, + { materialeId: 'mat-4', nome: 'Cerotti', unitaMisura: 'pz', quantita: 10 } + ] + } + ] + }; + + async function setup(id: string | null = 'lm-1'): Promise { + await TestBed.configureTestingModule({ + imports: [ListaModelloDettaglio], + providers: [ + provideRouter([]), + { provide: TipiEventoApiService, useValue: tipiEventoApi }, + { provide: ListeApiService, useValue: listeApi }, + { provide: Keycloak, useValue: keycloak }, + { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => id } } } } + ] + }).compileComponents(); + + router = TestBed.inject(Router); + vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + + fixture = TestBed.createComponent(ListaModelloDettaglio); + component = fixture.componentInstance; + fixture.detectChanges(); + + // whenStable() non intercetta in modo affidabile la Promise avviata da ngOnInit in questo + // componente (a differenza di altri con lo stesso pattern) — attendiamo esplicitamente che + // il caricamento finisca invece di fidarci del tracking automatico dello zone di test. + await flushUntil(() => !component.loading()); + } + + beforeEach(() => { + sessionStorage.clear(); + tipiEventoApi = { getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)) }; + listeApi = { getListaPubblica: vi.fn().mockReturnValue(of(lista)), creaListaDaFork: vi.fn() }; + }); + + it('mostra nome, chip tipo evento e voci della lista', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.lista-modello-dettaglio__titolo')?.textContent).toContain('Uscita di un giorno'); + expect(compiled.querySelector('.lista-modello-dettaglio__chip')?.textContent).toContain('Uscita'); + expect(compiled.textContent).toContain('Corda — 2 pz'); + expect(compiled.textContent).toContain('Torcia — 1 pz'); + }); + + it('mostra le sotto-liste con titolo e voci indentate', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.lista-modello-dettaglio__sotto-lista-titolo')?.textContent).toContain( + 'Kit di pronto soccorso' + ); + expect(compiled.textContent).toContain('Garze — 5 pz'); + expect(compiled.querySelector('.lista-modello-dettaglio__voci--indentate')).not.toBeNull(); + }); + + it('il checkbox della sotto-lista non è spuntato se manca almeno una voce interna', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + const checkboxSottoLista = compiled.querySelector( + '.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce input' + ) as HTMLInputElement; + expect(checkboxSottoLista.checked).toBe(false); + }); + + it('cliccando il checkbox della sotto-lista si spuntano automaticamente tutte le sue voci', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + const checkboxSottoLista = compiled.querySelector( + '.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce input' + ) as HTMLInputElement; + checkboxSottoLista.click(); + fixture.detectChanges(); + + expect(component.isVoceSpuntata('mat-3')).toBe(true); + expect(component.isVoceSpuntata('mat-4')).toBe(true); + const voci = compiled.querySelectorAll('.lista-modello-dettaglio__voci--indentate input'); + voci.forEach((input) => expect((input as HTMLInputElement).checked).toBe(true)); + }); + + it('cliccando di nuovo il checkbox della sotto-lista (tutta spuntata) la despunta tutta', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + const checkboxSottoLista = compiled.querySelector( + '.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce input' + ) as HTMLInputElement; + checkboxSottoLista.click(); + fixture.detectChanges(); + checkboxSottoLista.click(); + fixture.detectChanges(); + + expect(component.isVoceSpuntata('mat-3')).toBe(false); + expect(component.isVoceSpuntata('mat-4')).toBe(false); + }); + + it('mostra "Lista non trovata" se il caricamento fallisce', async () => { + listeApi = { getListaPubblica: vi.fn().mockReturnValue(throwError(() => new Error('404'))), creaListaDaFork: vi.fn() }; + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Lista non trovata'); + }); + + it('spunta una voce al click sulla checkbox e barra il testo, persistendo in sessionStorage', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + const checkbox = compiled.querySelector('.lista-modello-dettaglio__voce input') as HTMLInputElement; + checkbox.click(); + fixture.detectChanges(); + + const testo = compiled.querySelector('.lista-modello-dettaglio__voce span'); + expect(testo?.classList.contains('lista-modello-dettaglio__voce-testo--spuntata')).toBe(true); + expect(sessionStorage.getItem('scouthub-magazzino:lista-modello-voci-spuntate:lm-1')).toContain('mat-1'); + }); + + it('nasconde il pulsante "Usa come base" se non autenticato', async () => { + keycloak = { authenticated: false, login: vi.fn() }; + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('button')).toBeNull(); + expect(compiled.textContent).toContain('Accedi per usare questa lista come base'); + }); + + it('crea la lista privata e reindirizza quando autenticato', async () => { + keycloak = { authenticated: true, login: vi.fn() }; + const listaCreata: Lista = { + id: 'lp-1', + nome: 'Uscita di un giorno', + orgId: null, + stato: 'bozza', + statoModerazione: null, + tipoEventoId: null, + parentId: 'lm-1', + creataIl: '2026-01-01', + creataDaMe: true, + voci: [], + sottoListe: [] + }; + listeApi.creaListaDaFork.mockReturnValue(of(listaCreata)); + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + const button = compiled.querySelector('button') as HTMLButtonElement; + button.click(); + await flushUntil(() => (router.navigateByUrl as ReturnType).mock.calls.length > 0); + + expect(listeApi.creaListaDaFork).toHaveBeenCalledWith('lm-1'); + expect(router.navigateByUrl).toHaveBeenCalledWith('/liste/lp-1'); + }); +}); diff --git a/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.ts b/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.ts new file mode 100644 index 0000000..6bbb634 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/catalogo/lista-modello-dettaglio/lista-modello-dettaglio.ts @@ -0,0 +1,116 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import Keycloak from 'keycloak-js'; +import { firstValueFrom } from 'rxjs'; + +import { Lista, ListeApiService, SottoLista } from '../../liste/liste-api.service'; +import { TipiEventoApiService } from '../tipi-evento-api.service'; +import { VoceCheckStorageService } from '../voce-check-storage.service'; + +@Component({ + selector: 'app-lista-modello-dettaglio', + imports: [], + templateUrl: './lista-modello-dettaglio.html', + styleUrl: './lista-modello-dettaglio.css' +}) +export class ListaModelloDettaglio implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly tipiEventoApi = inject(TipiEventoApiService); + private readonly listeApi = inject(ListeApiService); + private readonly keycloak = inject(Keycloak); + private readonly voceCheckStorage = inject(VoceCheckStorageService); + + readonly loading = signal(true); + readonly lista = signal(null); + readonly tipoEventoNome = signal(''); + readonly creazioneInCorso = signal(false); + readonly creazioneErrore = signal(false); + + get isAuthenticated(): boolean { + return this.keycloak.authenticated ?? false; + } + + async ngOnInit(): Promise { + const id = this.route.snapshot.paramMap.get('id'); + if (!id) { + this.loading.set(false); + return; + } + await this.carica(id); + } + + isVoceSpuntata(materialeId: string): boolean { + const listaId = this.lista()?.id; + return !!listaId && this.voceCheckStorage.isSpuntata(listaId, materialeId); + } + + toggleVoce(materialeId: string): void { + const listaId = this.lista()?.id; + if (!listaId) { + return; + } + this.voceCheckStorage.toggle(listaId, materialeId); + } + + // Una sotto-lista è spuntata solo se lo sono tutte le sue voci materiale. + isSottoListaSpuntata(sottoLista: SottoLista): boolean { + const listaId = this.lista()?.id; + return ( + !!listaId && + sottoLista.voci.length > 0 && + sottoLista.voci.every((voce) => this.voceCheckStorage.isSpuntata(listaId, voce.materialeId)) + ); + } + + // Il checkbox sul nome della sotto-lista spunta/despunta in blocco tutte le sue voci. + toggleSottoLista(sottoLista: SottoLista): void { + const listaId = this.lista()?.id; + if (!listaId) { + return; + } + const nuovoStato = !this.isSottoListaSpuntata(sottoLista); + for (const voce of sottoLista.voci) { + this.voceCheckStorage.setSpuntata(listaId, voce.materialeId, nuovoStato); + } + } + + async usaComeBase(): Promise { + const lista = this.lista(); + if (!lista) { + return; + } + + if (!this.isAuthenticated) { + this.keycloak.login({ redirectUri: window.location.href }); + return; + } + + this.creazioneErrore.set(false); + this.creazioneInCorso.set(true); + + try { + const nuovaLista = await firstValueFrom(this.listeApi.creaListaDaFork(lista.id)); + this.creazioneInCorso.set(false); + await this.router.navigateByUrl(`/liste/${nuovaLista.id}`); + } catch { + this.creazioneInCorso.set(false); + this.creazioneErrore.set(true); + } + } + + private async carica(id: string): Promise { + this.loading.set(true); + + try { + const lista = await firstValueFrom(this.listeApi.getListaPubblica(id)); + const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEvento()); + this.lista.set(lista); + this.tipoEventoNome.set(tipiEvento.find((tipoEvento) => tipoEvento.id === lista.tipoEventoId)?.nome ?? ''); + } catch { + this.lista.set(null); + } finally { + this.loading.set(false); + } + } +} diff --git a/scouthub-magazzino-fe/src/app/catalogo/liste-api.service.ts b/scouthub-magazzino-fe/src/app/catalogo/liste-api.service.ts deleted file mode 100644 index 6fd04b4..0000000 --- a/scouthub-magazzino-fe/src/app/catalogo/liste-api.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; - -import { environment } from '../../environments/environment'; - -export interface ListaVoce { - materialeId: string; - nome: string; - unitaMisura: string; - quantita: number; -} - -export interface Lista { - id: string; - nome: string; - orgId: string; - creataIl: string; - voci: ListaVoce[]; -} - -@Injectable({ providedIn: 'root' }) -export class ListeApiService { - private readonly http = inject(HttpClient); - - creaListaDaModello(listaModelloId: string): Observable { - return this.http.post(`${environment.magazzinoApiBaseUrl}/liste/da-modello/${listaModelloId}`, {}); - } -} diff --git a/scouthub-magazzino-fe/src/app/catalogo/liste-modello-api.service.ts b/scouthub-magazzino-fe/src/app/catalogo/liste-modello-api.service.ts deleted file mode 100644 index 916f9df..0000000 --- a/scouthub-magazzino-fe/src/app/catalogo/liste-modello-api.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; - -import { environment } from '../../environments/environment'; - -export interface ListaModelloVoce { - materialeId: string; - nome: string; - unitaMisura: string; - quantita: number; -} - -export interface ListaModello { - id: string; - nome: string; - tipoEventoId: string; - voci: ListaModelloVoce[]; -} - -@Injectable({ providedIn: 'root' }) -export class ListeModelloApiService { - private readonly http = inject(HttpClient); - - getListeModello(): Observable { - return this.http.get(`${environment.magazzinoApiBaseUrl}/liste-modello`); - } -} diff --git a/scouthub-magazzino-fe/src/app/catalogo/materiali-api.service.ts b/scouthub-magazzino-fe/src/app/catalogo/materiali-api.service.ts index c342780..9417307 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/materiali-api.service.ts +++ b/scouthub-magazzino-fe/src/app/catalogo/materiali-api.service.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; @@ -17,6 +17,12 @@ export interface ProponiMaterialeInput { unitaMisura: string; } +export interface MaterialeInput { + nome: string; + categoria: string; + unitaMisura: string; +} + export interface MaterialeProposta { id: string; nome: string; @@ -31,11 +37,24 @@ export interface MaterialeProposta { export class MaterialiApiService { private readonly http = inject(HttpClient); - getMateriali(): Observable { - return this.http.get(`${environment.magazzinoApiBaseUrl}/materiali`); + getMateriali(nome?: string): Observable { + const params = nome ? new HttpParams().set('nome', nome) : undefined; + return this.http.get(`${environment.magazzinoApiBaseUrl}/materiali`, { params }); } proponiMateriale(input: ProponiMaterialeInput): Observable { return this.http.post(`${environment.magazzinoApiBaseUrl}/materiali/proposte`, input); } + + creaMateriale(input: MaterialeInput): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/materiali`, input); + } + + aggiornaMateriale(id: string, input: MaterialeInput): Observable { + return this.http.put(`${environment.magazzinoApiBaseUrl}/materiali/${id}`, input); + } + + eliminaMateriale(id: string): Observable { + return this.http.delete(`${environment.magazzinoApiBaseUrl}/materiali/${id}`); + } } diff --git a/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.css b/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.css deleted file mode 100644 index ab3332e..0000000 --- a/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.css +++ /dev/null @@ -1,21 +0,0 @@ -.proponi-materiale { - max-width: 520px; -} - -.proponi-materiale__field { - width: 100%; -} - -.proponi-materiale__campo-errore { - margin: 4px 0 0; - font-size: 12px; - color: var(--color-accent-800); -} - -.proponi-materiale__error { - color: var(--color-accent-800); -} - -.proponi-materiale__successo { - margin-bottom: var(--space-4); -} diff --git a/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.html b/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.html deleted file mode 100644 index aae44f2..0000000 --- a/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.html +++ /dev/null @@ -1,58 +0,0 @@ -
-

Proponi un nuovo materiale

-

- La proposta entra in coda di moderazione: il materiale verrà aggiunto al catalogo pubblico - solo dopo l'approvazione. -

- - @if (successo()) { -

- Proposta inviata: in attesa di moderazione -

- } - -
-
- - - @if (nome.hasError('required')) { -

Il nome è obbligatorio.

- } @else if (nome.hasError('minlength')) { -

Il nome deve avere almeno 2 caratteri.

- } @else if (nome.hasError('maxlength')) { -

Il nome non può superare i 100 caratteri.

- } -
- -
- - - @if (categoria.hasError('required')) { -

La categoria è obbligatoria.

- } @else if (categoria.hasError('maxlength')) { -

La categoria non può superare i 100 caratteri.

- } -
- -
- - - @if (unitaMisura.hasError('required')) { -

L'unità di misura è obbligatoria.

- } @else if (unitaMisura.hasError('maxlength')) { -

L'unità di misura non può superare i 20 caratteri.

- } -
- - @if (errorMessage(); as message) { - - } - -
- - Annulla -
-
-
diff --git a/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.spec.ts b/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.spec.ts deleted file mode 100644 index a5f7db2..0000000 --- a/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; -import { of, throwError } from 'rxjs'; - -import { MaterialeProposta, MaterialiApiService } from '../materiali-api.service'; -import { ProponiMateriale } from './proponi-materiale'; - -describe('ProponiMateriale', () => { - let component: ProponiMateriale; - let fixture: ComponentFixture; - let materialiApi: { proponiMateriale: ReturnType }; - - async function setup(nomeSuggerito: string | null = null): Promise { - materialiApi = { proponiMateriale: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [ProponiMateriale], - providers: [ - provideRouter([]), - { provide: MaterialiApiService, useValue: materialiApi }, - { - provide: ActivatedRoute, - useValue: { - snapshot: { queryParamMap: convertToParamMap(nomeSuggerito ? { nome: nomeSuggerito } : {}) } - } - } - ] - }).compileComponents(); - - fixture = TestBed.createComponent(ProponiMateriale); - component = fixture.componentInstance; - fixture.detectChanges(); - await fixture.whenStable(); - fixture.detectChanges(); - } - - it('precompila il nome dal query param', async () => { - await setup('Fune da bucato'); - - expect(component.nome.value).toBe('Fune da bucato'); - }); - - it('non chiama l\'API se il form non è valido e marca i controlli come touched', async () => { - await setup(); - component.nome.setValue(''); - - await component.submit(); - - expect(materialiApi.proponiMateriale).not.toHaveBeenCalled(); - expect(component.nome.touched).toBe(true); - expect(component.categoria.touched).toBe(true); - expect(component.unitaMisura.touched).toBe(true); - }); - - it('invia la proposta e mostra un messaggio di successo', async () => { - await setup(); - component.nome.setValue('Fune da bucato'); - component.categoria.setValue('Campeggio'); - component.unitaMisura.setValue('pz'); - - const proposta: MaterialeProposta = { - id: 'mat-nuovo', - nome: 'Fune da bucato', - categoria: 'Campeggio', - unitaMisura: 'pz', - stato: 'proposto', - propostoDaOrgId: 'org-1', - creatoIl: '2026-01-01T00:00:00.000Z' - }; - materialiApi.proponiMateriale.mockReturnValue(of(proposta)); - - await component.submit(); - - expect(materialiApi.proponiMateriale).toHaveBeenCalledWith({ - nome: 'Fune da bucato', - categoria: 'Campeggio', - unitaMisura: 'pz' - }); - expect(component.successo()).toBe(true); - expect(component.submitting()).toBe(false); - expect(component.nome.value).toBe(''); - }); - - it('mostra un messaggio di errore se l\'invio fallisce', async () => { - await setup(); - component.nome.setValue('Fune da bucato'); - component.categoria.setValue('Campeggio'); - component.unitaMisura.setValue('pz'); - materialiApi.proponiMateriale.mockReturnValue(throwError(() => new Error('server error'))); - - await component.submit(); - - expect(component.errorMessage()).toBe('Impossibile inviare la proposta. Riprova più tardi.'); - expect(component.successo()).toBe(false); - }); -}); diff --git a/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.ts b/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.ts deleted file mode 100644 index 03f72e6..0000000 --- a/scouthub-magazzino-fe/src/app/catalogo/proponi-materiale/proponi-materiale.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Component, inject, signal } from '@angular/core'; -import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; - -import { MaterialiApiService } from '../materiali-api.service'; - -@Component({ - selector: 'app-proponi-materiale', - imports: [ReactiveFormsModule, RouterLink, MatButtonModule, MatFormFieldModule, MatInputModule], - templateUrl: './proponi-materiale.html', - styleUrl: './proponi-materiale.css' -}) -export class ProponiMateriale { - private readonly route = inject(ActivatedRoute); - private readonly materialiApi = inject(MaterialiApiService); - - readonly nome = new FormControl(this.route.snapshot.queryParamMap.get('nome') ?? '', { - nonNullable: true, - validators: [Validators.required, Validators.minLength(2), Validators.maxLength(100)] - }); - readonly categoria = new FormControl('', { - nonNullable: true, - validators: [Validators.required, Validators.maxLength(100)] - }); - readonly unitaMisura = new FormControl('', { - nonNullable: true, - validators: [Validators.required, Validators.maxLength(20)] - }); - - readonly submitting = signal(false); - readonly errorMessage = signal(null); - readonly successo = signal(false); - - async submit(): Promise { - if (this.submitting()) { - return; - } - - if (this.nome.invalid || this.categoria.invalid || this.unitaMisura.invalid) { - this.nome.markAsTouched(); - this.categoria.markAsTouched(); - this.unitaMisura.markAsTouched(); - return; - } - - this.errorMessage.set(null); - this.successo.set(false); - this.submitting.set(true); - - try { - await firstValueFrom( - this.materialiApi.proponiMateriale({ - nome: this.nome.value.trim(), - categoria: this.categoria.value.trim(), - unitaMisura: this.unitaMisura.value.trim() - }) - ); - this.submitting.set(false); - this.successo.set(true); - this.nome.reset(''); - this.categoria.reset(''); - this.unitaMisura.reset(''); - } catch { - this.submitting.set(false); - this.errorMessage.set('Impossibile inviare la proposta. Riprova più tardi.'); - } - } -} diff --git a/scouthub-magazzino-fe/src/app/catalogo/tipi-evento-api.service.ts b/scouthub-magazzino-fe/src/app/catalogo/tipi-evento-api.service.ts index 8e0a043..f58374e 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/tipi-evento-api.service.ts +++ b/scouthub-magazzino-fe/src/app/catalogo/tipi-evento-api.service.ts @@ -1,19 +1,55 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; +export type StatoTipoEvento = 'confermata' | 'da_approvare'; + export interface TipoEvento { id: string; nome: string; + stato: StatoTipoEvento; + creatoDaOrgId: string | null; + creatoIl: string; } @Injectable({ providedIn: 'root' }) export class TipiEventoApiService { private readonly http = inject(HttpClient); - getTipiEvento(): Observable { - return this.http.get(`${environment.magazzinoApiBaseUrl}/tipi-evento`); + // Pubblico: solo i tipi evento confermati (catalogo liste, autocomplete wizard). + getTipiEvento(nome?: string): Observable { + const params = nome ? new HttpParams().set('nome', nome) : undefined; + return this.http.get(`${environment.magazzinoApiBaseUrl}/tipi-evento`, { params }); + } + + // Moderazione: tutti gli stati, incluse le proposte in attesa. + getTipiEventoModerazione(): Observable { + return this.http.get(`${environment.magazzinoApiBaseUrl}/tipi-evento/moderazione`); + } + + createTipoEvento(nome: string): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/tipi-evento`, { nome }); + } + + proponiTipoEvento(nome: string): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/tipi-evento/proposte`, { nome }); + } + + updateTipoEvento(id: string, nome: string): Observable { + return this.http.put(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}`, { nome }); + } + + deleteTipoEvento(id: string): Observable { + return this.http.delete(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}`); + } + + approvaTipoEvento(id: string): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}/approva`, {}); + } + + rifiutaTipoEvento(id: string): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}/rifiuta`, {}); } } diff --git a/scouthub-magazzino-fe/src/app/catalogo/voce-check-storage.service.ts b/scouthub-magazzino-fe/src/app/catalogo/voce-check-storage.service.ts new file mode 100644 index 0000000..3d5a83c --- /dev/null +++ b/scouthub-magazzino-fe/src/app/catalogo/voce-check-storage.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; + +// Le spunte sulle voci di una lista modello sono solo un promemoria visivo lato utente +// (niente checklist reale come per gli Eventi): non vanno al backend, restano nella +// sessionStorage del browser così sopravvivono alla navigazione tra home/dettaglio/ricerca +// ma si azzerano alla chiusura della scheda. +const STORAGE_KEY_PREFIX = 'scouthub-magazzino:lista-modello-voci-spuntate:'; + +@Injectable({ providedIn: 'root' }) +export class VoceCheckStorageService { + isSpuntata(listaModelloId: string, materialeId: string): boolean { + return this.leggi(listaModelloId).has(materialeId); + } + + toggle(listaModelloId: string, materialeId: string): void { + this.setSpuntata(listaModelloId, materialeId, !this.isSpuntata(listaModelloId, materialeId)); + } + + // Usato per il check/uncheck massivo di una sotto-lista dal suo checkbox riassuntivo: + // a differenza di toggle() imposta uno stato preciso invece di invertirlo. + setSpuntata(listaModelloId: string, materialeId: string, spuntata: boolean): void { + const spuntate = this.leggi(listaModelloId); + if (spuntata) { + spuntate.add(materialeId); + } else { + spuntate.delete(materialeId); + } + this.scrivi(listaModelloId, spuntate); + } + + private leggi(listaModelloId: string): Set { + try { + const raw = sessionStorage.getItem(STORAGE_KEY_PREFIX + listaModelloId); + return new Set(raw ? (JSON.parse(raw) as string[]) : []); + } catch { + return new Set(); + } + } + + private scrivi(listaModelloId: string, spuntate: Set): void { + try { + sessionStorage.setItem(STORAGE_KEY_PREFIX + listaModelloId, JSON.stringify([...spuntate])); + } catch { + // sessionStorage non disponibile (es. modalità privata restrittiva): la spunta + // resta solo per la sessione corrente del componente, nessun errore da mostrare. + } + } +} diff --git a/scouthub-magazzino-fe/src/app/core/models/notifica.model.ts b/scouthub-magazzino-fe/src/app/core/models/notifica.model.ts new file mode 100644 index 0000000..259611e --- /dev/null +++ b/scouthub-magazzino-fe/src/app/core/models/notifica.model.ts @@ -0,0 +1,14 @@ +export type TipoNotifica = + | 'MATERIALE_PROPOSTO' + | 'CATEGORIA_PROPOSTA' + | 'TIPO_EVENTO_PROPOSTO' + | 'LISTA_PROPOSTA'; + +export interface Notifica { + id: number; + tipo: TipoNotifica; + messaggio: string; + link: string | null; + letta: boolean; + dataCreazione: string; +} diff --git a/scouthub-magazzino-fe/src/app/core/services/notifiche.ts b/scouthub-magazzino-fe/src/app/core/services/notifiche.ts new file mode 100644 index 0000000..af8dbe3 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/core/services/notifiche.ts @@ -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.magazzinoApiBaseUrl}/notifiche`; + + getLista(): Observable { + return this.http.get(`${this.baseUrl}`); + } + + getCountNonLette(): Observable<{ count: number }> { + return this.http.get<{ count: number }>(`${this.baseUrl}/non-lette/count`); + } + + segnaLetta(id: number): Observable { + return this.http.put(`${this.baseUrl}/${id}/letta`, {}); + } + + segnaTutteLette(): Observable { + return this.http.put(`${this.baseUrl}/letta-tutte`, {}); + } +} diff --git a/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.css b/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.css deleted file mode 100644 index d760806..0000000 --- a/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.css +++ /dev/null @@ -1,17 +0,0 @@ -.crea-evento { - max-width: 520px; -} - -.crea-evento__field { - width: 100%; -} - -.crea-evento__campo-errore { - margin: 4px 0 0; - font-size: 12px; - color: var(--color-accent-800); -} - -.crea-evento__error { - color: var(--color-accent-800); -} diff --git a/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.html b/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.html deleted file mode 100644 index daa1400..0000000 --- a/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.html +++ /dev/null @@ -1,57 +0,0 @@ -
-

Nuovo evento

-

Crea un evento a partire da una lista della tua organizzazione.

- - @if (loading()) { -

Caricamento liste…

- } @else if (loadError(); as message) { - - } @else if (liste().length === 0) { -

- Non hai ancora nessuna lista da collegare a un evento. Creane una nella sezione - Le tue liste. -

- } @else { -
-
- - - @if (nome.hasError('required')) { -

Il nome dell'evento è obbligatorio.

- } @else if (nome.hasError('minlength')) { -

Il nome deve avere almeno 3 caratteri.

- } @else if (nome.hasError('maxlength')) { -

Il nome non può superare i 100 caratteri.

- } -
- -
- - - @if (data.hasError('required')) { -

La data è obbligatoria.

- } -
- -
- - - @if (listaId.hasError('required')) { -

Seleziona una lista.

- } -
- - @if (errorMessage(); as message) { - - } - - -
- } -
diff --git a/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.spec.ts b/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.spec.ts deleted file mode 100644 index f6d74ce..0000000 --- a/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter, Router } from '@angular/router'; -import { of, throwError } from 'rxjs'; - -import { Lista, ListeApiService } from '../../liste/liste-api.service'; -import { EventiApiService, EventoDettaglio } from '../eventi-api.service'; -import { CreaEvento } from './crea-evento'; - -describe('CreaEvento', () => { - let component: CreaEvento; - let fixture: ComponentFixture; - let listeApi: { getListe: ReturnType }; - let eventiApi: { creaEvento: ReturnType }; - let router: Router; - - const liste: Lista[] = [ - { id: 'lista-1', nome: 'Campo estivo 2026', orgId: 'org-1', creataIl: '2026-01-01', voci: [] }, - { id: 'lista-2', nome: 'Uscita di un giorno', orgId: 'org-1', creataIl: '2026-01-02', voci: [] } - ]; - - async function setup(): Promise { - await TestBed.configureTestingModule({ - imports: [CreaEvento], - providers: [ - provideRouter([]), - { provide: ListeApiService, useValue: listeApi }, - { provide: EventiApiService, useValue: eventiApi } - ] - }).compileComponents(); - - router = TestBed.inject(Router); - vi.spyOn(router, 'navigate').mockResolvedValue(true); - - fixture = TestBed.createComponent(CreaEvento); - component = fixture.componentInstance; - fixture.detectChanges(); - await fixture.whenStable(); - fixture.detectChanges(); - } - - beforeEach(() => { - listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) }; - eventiApi = { creaEvento: vi.fn() }; - }); - - it('mostra le liste disponibili nel select', async () => { - await setup(); - - expect(component.loading()).toBe(false); - expect(component.liste()).toEqual(liste); - }); - - it('mostra un messaggio se non ci sono liste disponibili', async () => { - listeApi.getListe.mockReturnValue(of([])); - - await setup(); - - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Non hai ancora nessuna lista'); - }); - - it('mostra un messaggio di errore se il caricamento delle liste fallisce', async () => { - listeApi.getListe.mockReturnValue(throwError(() => new Error('network error'))); - - await setup(); - - expect(component.loadError()).toBe('Impossibile caricare le liste disponibili. Riprova più tardi.'); - }); - - describe('validazione e invio del form', () => { - beforeEach(async () => { - await setup(); - }); - - it('non chiama l\'API se il form non è valido e marca i controlli come touched', async () => { - await component.submit(); - - expect(eventiApi.creaEvento).not.toHaveBeenCalled(); - expect(component.nome.touched).toBe(true); - expect(component.data.touched).toBe(true); - expect(component.listaId.touched).toBe(true); - }); - - it('crea l\'evento e reindirizza al suo dettaglio', async () => { - component.nome.setValue('Campo estivo 2026'); - component.data.setValue('2026-08-01'); - component.listaId.setValue('lista-1'); - - const evento: EventoDettaglio = { - id: 'evento-1', - orgId: 'org-1', - nome: 'Campo estivo 2026', - listaId: 'lista-1', - data: '2026-08-01T00:00:00.000Z', - voci: [] - }; - eventiApi.creaEvento.mockReturnValue(of(evento)); - - await component.submit(); - - expect(eventiApi.creaEvento).toHaveBeenCalledWith({ - nome: 'Campo estivo 2026', - data: '2026-08-01', - listaId: 'lista-1' - }); - expect(router.navigate).toHaveBeenCalledWith(['/eventi', 'evento-1']); - expect(component.submitting()).toBe(false); - }); - - it('mostra un messaggio di errore se la creazione fallisce', async () => { - component.nome.setValue('Campo estivo 2026'); - component.data.setValue('2026-08-01'); - component.listaId.setValue('lista-1'); - eventiApi.creaEvento.mockReturnValue(throwError(() => new Error('server error'))); - - await component.submit(); - - expect(component.errorMessage()).toBe("Impossibile creare l'evento. Riprova più tardi."); - expect(router.navigate).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.ts b/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.ts deleted file mode 100644 index f2ce463..0000000 --- a/scouthub-magazzino-fe/src/app/eventi/crea-evento/crea-evento.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; -import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { Router, RouterLink } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; - -import { Lista, ListeApiService } from '../../liste/liste-api.service'; -import { EventiApiService } from '../eventi-api.service'; - -@Component({ - selector: 'app-crea-evento', - imports: [ReactiveFormsModule, RouterLink, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule], - templateUrl: './crea-evento.html', - styleUrl: './crea-evento.css' -}) -export class CreaEvento implements OnInit { - private readonly listeApi = inject(ListeApiService); - private readonly eventiApi = inject(EventiApiService); - private readonly router = inject(Router); - - readonly loading = signal(true); - readonly loadError = signal(null); - readonly liste = signal([]); - - readonly nome = new FormControl('', { - nonNullable: true, - validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)] - }); - readonly data = new FormControl('', { nonNullable: true, validators: [Validators.required] }); - readonly listaId = new FormControl(null, { validators: [Validators.required] }); - - readonly submitting = signal(false); - readonly errorMessage = signal(null); - - async ngOnInit(): Promise { - await this.caricaListe(); - } - - async submit(): Promise { - if (this.submitting()) { - return; - } - - if (this.nome.invalid || this.data.invalid || this.listaId.invalid) { - this.nome.markAsTouched(); - this.data.markAsTouched(); - this.listaId.markAsTouched(); - return; - } - - this.errorMessage.set(null); - this.submitting.set(true); - - try { - const evento = await firstValueFrom( - this.eventiApi.creaEvento({ - nome: this.nome.value.trim(), - data: this.data.value, - listaId: this.listaId.value! - }) - ); - this.submitting.set(false); - await this.router.navigate(['/eventi', evento.id]); - } catch { - this.submitting.set(false); - this.errorMessage.set("Impossibile creare l'evento. Riprova più tardi."); - } - } - - private async caricaListe(): Promise { - this.loading.set(true); - this.loadError.set(null); - - try { - const liste = await firstValueFrom(this.listeApi.getListe()); - this.liste.set(liste); - } catch { - this.loadError.set('Impossibile caricare le liste disponibili. Riprova più tardi.'); - } finally { - this.loading.set(false); - } - } -} diff --git a/scouthub-magazzino-fe/src/app/eventi/eventi-api.service.ts b/scouthub-magazzino-fe/src/app/eventi/eventi-api.service.ts deleted file mode 100644 index 267be12..0000000 --- a/scouthub-magazzino-fe/src/app/eventi/eventi-api.service.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; - -import { environment } from '../../environments/environment'; - -export interface EventoVoce { - materialeId: string; - nome: string; - unitaMisura: string; - quantitaRichiesta: number; - quantitaPosseduta: number; - portato: boolean; - note: string | null; -} - -export interface EventoDettaglio { - id: string; - orgId: string; - nome: string; - listaId: string; - data: string; - voci: EventoVoce[]; -} - -export interface CreaEventoInput { - nome: string; - listaId: string; - data: string; -} - -export interface CheckVoceInput { - materialeId: string; - portato?: boolean; - note?: string | null; -} - -@Injectable({ providedIn: 'root' }) -export class EventiApiService { - private readonly http = inject(HttpClient); - - creaEvento(input: CreaEventoInput): Observable { - return this.http.post(`${environment.magazzinoApiBaseUrl}/eventi`, input); - } - - getEvento(id: string): Observable { - return this.http.get(`${environment.magazzinoApiBaseUrl}/eventi/${id}`); - } - - aggiornaCheck(id: string, voci: CheckVoceInput[]): Observable { - return this.http.patch(`${environment.magazzinoApiBaseUrl}/eventi/${id}/check`, { voci }); - } -} diff --git a/scouthub-magazzino-fe/src/app/eventi/eventi.routes.ts b/scouthub-magazzino-fe/src/app/eventi/eventi.routes.ts deleted file mode 100644 index 27490fa..0000000 --- a/scouthub-magazzino-fe/src/app/eventi/eventi.routes.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Routes } from '@angular/router'; - -import { requireAuthGuard } from '../core/require-auth.guard'; - -export const EVENTI_ROUTES: Routes = [ - { - path: '', - loadComponent: () => import('./crea-evento/crea-evento').then((m) => m.CreaEvento), - canActivate: [requireAuthGuard] - }, - { - path: ':id', - loadComponent: () => import('./evento-dettaglio/evento-dettaglio').then((m) => m.EventoDettaglioComponent), - canActivate: [requireAuthGuard] - } -]; diff --git a/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.css b/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.css deleted file mode 100644 index 59e2830..0000000 --- a/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.css +++ /dev/null @@ -1,52 +0,0 @@ -.evento-dettaglio { - max-width: 800px; -} - -.evento-dettaglio__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - margin-bottom: var(--space-2); -} - -.evento-dettaglio__data { - color: var(--color-text); - opacity: 0.7; - margin: 0; -} - -.evento-dettaglio__tabella { - margin-top: var(--space-5); - margin-bottom: var(--space-5); -} - -.evento-dettaglio__quantita--insufficiente { - color: var(--color-accent-800); - font-weight: 600; -} - -.evento-dettaglio__badge-manca { - margin-left: var(--space-2); -} - -.evento-dettaglio__checkbox { - width: 18px; - height: 18px; - accent-color: var(--color-accent); - cursor: pointer; -} - -.evento-dettaglio__note { - width: 100%; - min-height: 32px; -} - -.evento-dettaglio__error { - color: var(--color-accent-800); -} - -.evento-dettaglio__successo { - display: inline-flex; - margin-bottom: var(--space-3); -} diff --git a/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.html b/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.html deleted file mode 100644 index ad0bc9c..0000000 --- a/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.html +++ /dev/null @@ -1,74 +0,0 @@ -
- @if (loading()) { -

Caricamento evento…

- } @else if (loadError(); as message) { - - } @else if (evento(); as ev) { -
-
-

{{ ev.nome }}

-

{{ ev.data | slice: 0 : 10 }}

-
- Crea un altro evento -
- - - - - - - - - - - - - @for (voce of voci(); track voce.materialeId) { - - - - - - - - } @empty { - - - - } - -
MaterialeRichiestaIn magazzinoPortatoNote
{{ voce.nome }}{{ voce.quantitaRichiesta }} {{ voce.unitaMisura }} - {{ voce.quantitaPosseduta }} {{ voce.unitaMisura }} - @if (quantitaInsufficiente(voce)) { - manca - } - - - - -
La lista collegata a questo evento non ha voci.
- - @if (salvataggioErrore(); as message) { - - } - @if (salvataggioOk()) { -

Check salvato

- } - - - } -
diff --git a/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.spec.ts b/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.spec.ts deleted file mode 100644 index bb891b6..0000000 --- a/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, provideRouter } from '@angular/router'; -import { of, throwError } from 'rxjs'; - -import { EventiApiService, EventoDettaglio } from '../eventi-api.service'; -import { EventoDettaglioComponent } from './evento-dettaglio'; - -describe('EventoDettaglioComponent', () => { - let component: EventoDettaglioComponent; - let fixture: ComponentFixture; - let eventiApi: { getEvento: ReturnType; aggiornaCheck: ReturnType }; - - const eventoIniziale: EventoDettaglio = { - id: 'evento-1', - orgId: 'org-1', - nome: 'Campo estivo 2026', - listaId: 'lista-1', - data: '2026-08-01T00:00:00.000Z', - voci: [ - { - materialeId: 'mat-1', - nome: 'Corda', - unitaMisura: 'pz', - quantitaRichiesta: 5, - quantitaPosseduta: 2, - portato: false, - note: null - }, - { - materialeId: 'mat-2', - nome: 'Telo cerato', - unitaMisura: 'pz', - quantitaRichiesta: 1, - quantitaPosseduta: 3, - portato: true, - note: 'Controllato' - } - ] - }; - - async function setup(eventoId = 'evento-1'): Promise { - await TestBed.configureTestingModule({ - imports: [EventoDettaglioComponent], - providers: [ - provideRouter([]), - { provide: EventiApiService, useValue: eventiApi }, - { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => eventoId } } } } - ] - }).compileComponents(); - - fixture = TestBed.createComponent(EventoDettaglioComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - await fixture.whenStable(); - fixture.detectChanges(); - } - - beforeEach(() => { - eventiApi = { getEvento: vi.fn().mockReturnValue(of(eventoIniziale)), aggiornaCheck: vi.fn() }; - }); - - it('mostra le voci con quantità richiesta e disponibile in magazzino', async () => { - await setup(); - fixture.detectChanges(); - - expect(component.loading()).toBe(false); - - const compiled = fixture.nativeElement as HTMLElement; - const righe = compiled.querySelectorAll('.evento-dettaglio__tabella tbody tr'); - expect(righe.length).toBe(2); - expect(righe[0].textContent).toContain('Corda'); - expect(righe[0].textContent).toContain('5 pz'); - expect(righe[0].textContent).toContain('2 pz'); - }); - - it('evidenzia con un badge la quantità insufficiente in magazzino', async () => { - await setup(); - - expect(component.quantitaInsufficiente(component.voci()[0])).toBe(true); - expect(component.quantitaInsufficiente(component.voci()[1])).toBe(false); - - const compiled = fixture.nativeElement as HTMLElement; - const badge = compiled.querySelector('.evento-dettaglio__badge-manca'); - expect(badge).toBeTruthy(); - expect(compiled.querySelectorAll('.evento-dettaglio__badge-manca').length).toBe(1); - }); - - it('mostra un messaggio di errore se il caricamento fallisce', async () => { - eventiApi.getEvento.mockReturnValue(throwError(() => new Error('network error'))); - - await setup(); - - expect(component.loadError()).toBe("Impossibile caricare l'evento. Riprova più tardi."); - }); - - describe('modifica del check locale', () => { - beforeEach(async () => { - await setup(); - }); - - it('aggiorna lo stato "portato" di una voce', () => { - component.togglePortato('mat-1', true); - - expect(component.voci().find((v) => v.materialeId === 'mat-1')?.portato).toBe(true); - }); - - it('aggiorna le note di una voce', () => { - component.modificaNote('mat-1', 'Verificare prima di partire'); - - expect(component.voci().find((v) => v.materialeId === 'mat-1')?.note).toBe('Verificare prima di partire'); - }); - }); - - describe('salvataggio del check', () => { - beforeEach(async () => { - await setup(); - }); - - it('invia tutte le voci a aggiornaCheck con note vuote normalizzate a null', async () => { - component.togglePortato('mat-1', true); - component.modificaNote('mat-1', ' '); - - const eventoAggiornato: EventoDettaglio = { - ...eventoIniziale, - voci: eventoIniziale.voci.map((v) => (v.materialeId === 'mat-1' ? { ...v, portato: true } : v)) - }; - eventiApi.aggiornaCheck.mockReturnValue(of(eventoAggiornato)); - - await component.salva(); - - expect(eventiApi.aggiornaCheck).toHaveBeenCalledWith('evento-1', [ - { materialeId: 'mat-1', portato: true, note: null }, - { materialeId: 'mat-2', portato: true, note: 'Controllato' } - ]); - expect(component.salvataggioOk()).toBe(true); - expect(component.voci()[0].portato).toBe(true); - }); - - it('mostra un messaggio di errore se il salvataggio fallisce', async () => { - eventiApi.aggiornaCheck.mockReturnValue(throwError(() => new Error('server error'))); - - await component.salva(); - - expect(component.salvataggioErrore()).toBe('Impossibile salvare il check della lista. Riprova più tardi.'); - expect(component.salvataggioOk()).toBe(false); - }); - }); -}); diff --git a/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.ts b/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.ts deleted file mode 100644 index f475c9e..0000000 --- a/scouthub-magazzino-fe/src/app/eventi/evento-dettaglio/evento-dettaglio.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { SlicePipe } from '@angular/common'; -import { Component, OnInit, inject, signal } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatInputModule } from '@angular/material/input'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; - -import { CheckVoceInput, EventiApiService, EventoDettaglio } from '../eventi-api.service'; - -interface VoceCheckEditor { - materialeId: string; - nome: string; - unitaMisura: string; - quantitaRichiesta: number; - quantitaPosseduta: number; - portato: boolean; - note: string; -} - -function toEditor(voci: EventoDettaglio['voci']): VoceCheckEditor[] { - return voci.map((v) => ({ ...v, note: v.note ?? '' })); -} - -@Component({ - selector: 'app-evento-dettaglio', - imports: [SlicePipe, RouterLink, MatButtonModule, MatCheckboxModule, MatInputModule], - templateUrl: './evento-dettaglio.html', - styleUrl: './evento-dettaglio.css' -}) -export class EventoDettaglioComponent implements OnInit { - private readonly route = inject(ActivatedRoute); - private readonly eventiApi = inject(EventiApiService); - - private readonly eventoId = this.route.snapshot.paramMap.get('id') ?? ''; - - readonly loading = signal(true); - readonly loadError = signal(null); - readonly evento = signal(null); - readonly voci = signal([]); - - readonly salvataggioInCorso = signal(false); - readonly salvataggioErrore = signal(null); - readonly salvataggioOk = signal(false); - - async ngOnInit(): Promise { - await this.carica(); - } - - quantitaInsufficiente(voce: VoceCheckEditor): boolean { - return voce.quantitaPosseduta < voce.quantitaRichiesta; - } - - togglePortato(materialeId: string, portato: boolean): void { - this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, portato } : v))); - } - - modificaNote(materialeId: string, note: string): void { - this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, note } : v))); - } - - async salva(): Promise { - if (this.salvataggioInCorso()) { - return; - } - - this.salvataggioErrore.set(null); - this.salvataggioOk.set(false); - this.salvataggioInCorso.set(true); - - try { - const input: CheckVoceInput[] = this.voci().map((v) => ({ - materialeId: v.materialeId, - portato: v.portato, - note: v.note.trim().length > 0 ? v.note.trim() : null - })); - const eventoAggiornato = await firstValueFrom(this.eventiApi.aggiornaCheck(this.eventoId, input)); - this.evento.set(eventoAggiornato); - this.voci.set(toEditor(eventoAggiornato.voci)); - this.salvataggioOk.set(true); - } catch { - this.salvataggioErrore.set('Impossibile salvare il check della lista. Riprova più tardi.'); - } finally { - this.salvataggioInCorso.set(false); - } - } - - private async carica(): Promise { - this.loading.set(true); - this.loadError.set(null); - - try { - const evento = await firstValueFrom(this.eventiApi.getEvento(this.eventoId)); - this.evento.set(evento); - this.voci.set(toEditor(evento.voci)); - } catch { - this.loadError.set('Impossibile caricare l\'evento. Riprova più tardi.'); - } finally { - this.loading.set(false); - } - } -} diff --git a/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.css b/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.css deleted file mode 100644 index a7a915a..0000000 --- a/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.css +++ /dev/null @@ -1,59 +0,0 @@ -.lista-editor { - max-width: 720px; -} - -.lista-editor__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - margin-bottom: 0; -} - -.lista-editor__tabella { - margin-bottom: var(--space-5); -} - -.lista-editor__quantita { - width: 4.5rem; - min-height: 32px; - padding: 4px 10px; -} - -.lista-editor__ricerca { - margin-bottom: var(--space-5); -} - -.lista-editor__ricerca-field { - width: 100%; -} - -.lista-editor__risultati { - list-style: none; - margin: var(--space-2) 0 0; - padding: 0; -} - -.lista-editor__risultati li { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - padding: var(--space-2) 0; - border-bottom: 1px solid var(--color-divider); -} - -.lista-editor__nessun-risultato { - color: var(--color-text); - opacity: 0.7; - font-size: 14px; -} - -.lista-editor__error { - color: var(--color-accent-800); -} - -.lista-editor__successo { - display: inline-flex; - margin-bottom: var(--space-3); -} diff --git a/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.html b/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.html deleted file mode 100644 index 7d27890..0000000 --- a/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.html +++ /dev/null @@ -1,92 +0,0 @@ -
- @if (loading()) { -

Caricamento lista…

- } @else if (loadError(); as message) { - - } @else { -
-

{{ lista()?.nome }}

- Torna alle tue liste -
-

Le liste filtrano sempre per la tua organizzazione.

- - - - - - - - - - - - @for (voce of voci(); track voce.materialeId) { - - - - - - - } @empty { - - - - } - -
MaterialeQuantitàUnità
{{ voce.nome }} - - {{ voce.unitaMisura }} - -
Nessun materiale in questa lista.
- -
-
- - -
- - @if (risultatiRicerca().length > 0) { -
    - @for (materiale of risultatiRicerca(); track materiale.id) { -
  • - {{ materiale.nome }} ({{ materiale.categoria }}) - -
  • - } -
- } @else if (nessunRisultato()) { -

- Nessun materiale trovato per "{{ ricerca.value }}". - - Proponi un nuovo materiale - -

- } -
- - @if (salvataggioErrore(); as message) { - - } - @if (salvataggioOk()) { -

Modifiche salvate

- } - - - } -
diff --git a/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.spec.ts b/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.spec.ts deleted file mode 100644 index 7770f2f..0000000 --- a/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, provideRouter } from '@angular/router'; -import { of, throwError } from 'rxjs'; - -import { MaterialePubblico, MaterialiApiService } from '../../catalogo/materiali-api.service'; -import { Lista, ListeApiService } from '../liste-api.service'; -import { ListaEditor } from './lista-editor'; - -describe('ListaEditor', () => { - let component: ListaEditor; - let fixture: ComponentFixture; - let listeApi: { getListe: ReturnType; aggiornaLista: ReturnType }; - let materialiApi: { getMateriali: ReturnType }; - - const listaIniziale: Lista = { - id: 'lista-1', - nome: 'Campo estivo 2026', - orgId: 'org-1', - creataIl: '2026-01-01', - voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }] - }; - - const materialiCatalogo: MaterialePubblico[] = [ - { id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' }, - { id: 'mat-2', nome: 'Telo cerato', categoria: 'Campeggio', unitaMisura: 'pz' }, - { id: 'mat-3', nome: 'Torcia', categoria: 'Attrezzatura', unitaMisura: 'pz' } - ]; - - async function setup(listaId = 'lista-1'): Promise { - await TestBed.configureTestingModule({ - imports: [ListaEditor], - providers: [ - provideRouter([]), - { provide: ListeApiService, useValue: listeApi }, - { provide: MaterialiApiService, useValue: materialiApi }, - { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => listaId } } } } - ] - }).compileComponents(); - - fixture = TestBed.createComponent(ListaEditor); - component = fixture.componentInstance; - fixture.detectChanges(); - await fixture.whenStable(); - fixture.detectChanges(); - } - - beforeEach(() => { - listeApi = { getListe: vi.fn().mockReturnValue(of([listaIniziale])), aggiornaLista: vi.fn() }; - materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materialiCatalogo)) }; - }); - - it('mostra le voci della lista dopo il caricamento', async () => { - await setup(); - fixture.detectChanges(); - - expect(component.loading()).toBe(false); - expect(component.voci()).toEqual(listaIniziale.voci); - - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Campo estivo 2026'); - const righe = compiled.querySelectorAll('.lista-editor__tabella tbody tr'); - expect(righe.length).toBe(1); - expect(righe[0].textContent).toContain('Corda'); - }); - - it('mostra un errore se la lista non viene trovata', async () => { - listeApi.getListe.mockReturnValue(of([])); - - await setup(); - - expect(component.loadError()).toBe('Lista non trovata.'); - }); - - it('mostra un errore se il caricamento fallisce', async () => { - listeApi.getListe.mockReturnValue(throwError(() => new Error('network error'))); - - await setup(); - - expect(component.loadError()).toBe('Impossibile caricare la lista. Riprova più tardi.'); - }); - - describe('ricerca e aggiunta materiali dal catalogo', () => { - beforeEach(async () => { - await setup(); - }); - - it('esclude dai risultati i materiali già presenti in lista', () => { - component.ricerca.setValue('corda'); - fixture.detectChanges(); - - expect(component.risultatiRicerca()).toEqual([]); - }); - - it('trova un materiale non ancora in lista e lo aggiunge al click su "Aggiungi"', () => { - component.ricerca.setValue('telo'); - fixture.detectChanges(); - - expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['mat-2']); - - component.aggiungiMateriale(materialiCatalogo[1]); - fixture.detectChanges(); - - expect(component.voci().map((v) => v.materialeId)).toEqual(['mat-1', 'mat-2']); - expect(component.ricerca.value).toBe(''); - - const compiled = fixture.nativeElement as HTMLElement; - const righe = compiled.querySelectorAll('.lista-editor__tabella tbody tr'); - expect(righe.length).toBe(2); - }); - - it('rimuove una voce dalla lista', () => { - component.rimuoviVoce('mat-1'); - fixture.detectChanges(); - - expect(component.voci()).toEqual([]); - - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.lista-editor__tabella tbody')?.textContent).toContain( - 'Nessun materiale in questa lista.' - ); - }); - - it('modifica la quantità di una voce esistente', () => { - component.modificaQuantita('mat-1', '5'); - - expect(component.voci()[0].quantita).toBe(5); - }); - - it('ignora una quantità non valida e mantiene almeno 1', () => { - component.modificaQuantita('mat-1', 'abc'); - - expect(component.voci()[0].quantita).toBe(1); - }); - - it('mostra il link per proporre un nuovo materiale se la ricerca non trova risultati', () => { - component.ricerca.setValue('materiale inesistente'); - fixture.detectChanges(); - - expect(component.nessunRisultato()).toBe(true); - - const compiled = fixture.nativeElement as HTMLElement; - const link = compiled.querySelector('.lista-editor__nessun-risultato a') as HTMLAnchorElement; - expect(link).toBeTruthy(); - expect(link.getAttribute('href')).toBe('/proponi-materiale?nome=materiale%20inesistente'); - }); - }); - - describe('salvataggio delle modifiche', () => { - beforeEach(async () => { - await setup(); - }); - - it('invia le voci correnti a aggiornaLista e mostra un messaggio di successo', async () => { - component.rimuoviVoce('mat-1'); - component.aggiungiMateriale(materialiCatalogo[2]); - - const listaAggiornata: Lista = { - ...listaIniziale, - voci: [{ materialeId: 'mat-3', nome: 'Torcia', unitaMisura: 'pz', quantita: 1 }] - }; - listeApi.aggiornaLista.mockReturnValue(of(listaAggiornata)); - - await component.salva(); - - expect(listeApi.aggiornaLista).toHaveBeenCalledWith('lista-1', { - voci: [{ materialeId: 'mat-3', quantita: 1 }] - }); - expect(component.salvataggioOk()).toBe(true); - expect(component.voci()).toEqual(listaAggiornata.voci); - }); - - it('mostra un messaggio di errore se il salvataggio fallisce', async () => { - listeApi.aggiornaLista.mockReturnValue(throwError(() => new Error('server error'))); - - await component.salva(); - - expect(component.salvataggioErrore()).toBe('Impossibile salvare le modifiche. Riprova più tardi.'); - expect(component.salvataggioOk()).toBe(false); - }); - }); -}); diff --git a/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.ts b/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.ts deleted file mode 100644 index 7e02cab..0000000 --- a/scouthub-magazzino-fe/src/app/liste/lista-editor/lista-editor.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Component, OnInit, computed, inject, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; - -import { MaterialePubblico, MaterialiApiService } from '../../catalogo/materiali-api.service'; -import { Lista, ListeApiService } from '../liste-api.service'; - -interface VoceEditor { - materialeId: string; - nome: string; - unitaMisura: string; - quantita: number; -} - -@Component({ - selector: 'app-lista-editor', - imports: [ReactiveFormsModule, RouterLink, MatButtonModule, MatFormFieldModule, MatInputModule], - templateUrl: './lista-editor.html', - styleUrl: './lista-editor.css' -}) -export class ListaEditor implements OnInit { - private readonly route = inject(ActivatedRoute); - private readonly listeApi = inject(ListeApiService); - private readonly materialiApi = inject(MaterialiApiService); - - private readonly listaId = this.route.snapshot.paramMap.get('id') ?? ''; - - readonly loading = signal(true); - readonly loadError = signal(null); - readonly lista = signal(null); - readonly voci = signal([]); - readonly materialiCatalogo = signal([]); - - readonly ricerca = new FormControl('', { nonNullable: true }); - private readonly ricercaValue = toSignal(this.ricerca.valueChanges, { initialValue: '' }); - - readonly salvataggioInCorso = signal(false); - readonly salvataggioErrore = signal(null); - readonly salvataggioOk = signal(false); - - readonly risultatiRicerca = computed(() => { - const termine = this.ricercaValue().trim().toLowerCase(); - if (termine.length === 0) { - return []; - } - - const idGiaPresenti = new Set(this.voci().map((v) => v.materialeId)); - return this.materialiCatalogo().filter( - (materiale) => !idGiaPresenti.has(materiale.id) && materiale.nome.toLowerCase().includes(termine) - ); - }); - - readonly nessunRisultato = computed( - () => this.ricercaValue().trim().length > 0 && this.risultatiRicerca().length === 0 - ); - - async ngOnInit(): Promise { - await this.carica(); - } - - aggiungiMateriale(materiale: MaterialePubblico): void { - if (this.voci().some((v) => v.materialeId === materiale.id)) { - return; - } - - this.voci.update((voci) => [ - ...voci, - { materialeId: materiale.id, nome: materiale.nome, unitaMisura: materiale.unitaMisura, quantita: 1 } - ]); - this.ricerca.setValue(''); - } - - rimuoviVoce(materialeId: string): void { - this.voci.update((voci) => voci.filter((v) => v.materialeId !== materialeId)); - } - - modificaQuantita(materialeId: string, valore: string): void { - const parsed = Number.parseInt(valore, 10); - const quantita = Number.isInteger(parsed) && parsed > 0 ? parsed : 1; - this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, quantita } : v))); - } - - async salva(): Promise { - if (this.salvataggioInCorso()) { - return; - } - - this.salvataggioErrore.set(null); - this.salvataggioOk.set(false); - this.salvataggioInCorso.set(true); - - try { - const input = { voci: this.voci().map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) }; - const listaAggiornata = await firstValueFrom(this.listeApi.aggiornaLista(this.listaId, input)); - this.lista.set(listaAggiornata); - this.voci.set(listaAggiornata.voci); - this.salvataggioOk.set(true); - } catch { - this.salvataggioErrore.set('Impossibile salvare le modifiche. Riprova più tardi.'); - } finally { - this.salvataggioInCorso.set(false); - } - } - - private async carica(): Promise { - this.loading.set(true); - this.loadError.set(null); - - try { - const liste = await firstValueFrom(this.listeApi.getListe()); - const lista = liste.find((l) => l.id === this.listaId) ?? null; - if (!lista) { - this.loadError.set('Lista non trovata.'); - return; - } - - this.lista.set(lista); - this.voci.set(lista.voci); - - const materiali = await firstValueFrom(this.materialiApi.getMateriali()); - this.materialiCatalogo.set(materiali); - } catch { - this.loadError.set('Impossibile caricare la lista. Riprova più tardi.'); - } finally { - this.loading.set(false); - } - } -} diff --git a/scouthub-magazzino-fe/src/app/liste/lista.model.ts b/scouthub-magazzino-fe/src/app/liste/lista.model.ts new file mode 100644 index 0000000..d60b866 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/liste/lista.model.ts @@ -0,0 +1,34 @@ +export interface StatoLista { + id: string; + nome: string; +} + +export const STATO_BOZZA: StatoLista = { id: 'bozza', nome: 'Bozza' }; +export const STATO_PRIVATO: StatoLista = { id: 'privato', nome: 'Privato' }; +export const STATO_GRUPPO: StatoLista = { id: 'gruppo', nome: 'Gruppo' }; +export const STATO_PUBBLICO: StatoLista = { id: 'pubblico', nome: 'Pubblico' }; + +export const STATI_LISTA: StatoLista[] = [STATO_BOZZA, STATO_PRIVATO, STATO_GRUPPO, STATO_PUBBLICO]; + +export interface StatoListaStyle { + bg: string; + color: string; + border: string; +} + +export function statoListaStyle(idStato: string): StatoListaStyle { + if (idStato === 'gruppo') { + return { bg: 'var(--color-accent-100)', color: 'var(--color-accent-800)', border: 'var(--color-accent-300)' }; + } + if (idStato === 'pubblico') { + return { + bg: 'var(--color-accent-2-100)', + color: 'var(--color-accent-2-800)', + border: 'var(--color-accent-2-300)' + }; + } + if (idStato === 'privato') { + return { bg: 'var(--color-neutral-300)', color: 'var(--color-neutral-900)', border: 'var(--color-neutral-400)' }; + } + return { bg: 'var(--color-neutral-100)', color: 'var(--color-neutral-700)', border: 'var(--color-neutral-300)' }; +} diff --git a/scouthub-magazzino-fe/src/app/liste/liste-api.service.ts b/scouthub-magazzino-fe/src/app/liste/liste-api.service.ts index d1df104..1b9da42 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste-api.service.ts +++ b/scouthub-magazzino-fe/src/app/liste/liste-api.service.ts @@ -9,14 +9,38 @@ export interface ListaVoce { nome: string; unitaMisura: string; quantita: number; + // Presente solo se il chiamante può vederlo (creatore della lista o admin/moderatore): + // il materiale è stato proposto da poco e non è ancora stato approvato. + inAttesaConferma?: boolean; } +export interface SottoLista { + id: string; + nome: string; + voci: ListaVoce[]; +} + +export type StatoModerazioneLista = 'proposto' | 'approvato' | 'rifiutato'; + export interface Lista { id: string; nome: string; - orgId: string; + // Valorizzato solo per le liste in stato 'gruppo': le liste bozza/privato/pubblico + // sono personali e non hanno alcuna organizzazione. + orgId: string | null; + stato: string; + // Nullo per bozza/privato/gruppo: la moderazione si applica solo quando stato = + // pubblico. + statoModerazione: StatoModerazioneLista | null; + tipoEventoId: string | null; + // Da quale lista pubblica approvata è stata forkata questa (fork = "usa come base"). + parentId: string | null; creataIl: string; + // Il chiamante è l'autore: per le liste 'gruppo' (visibili a tutta l'org, non solo a + // chi le ha create) serve a distinguere "le mie liste di gruppo" dalle altrui. + creataDaMe: boolean; voci: ListaVoce[]; + sottoListe: SottoLista[]; } export interface ListaVoceInput { @@ -24,9 +48,28 @@ export interface ListaVoceInput { quantita: number; } +export interface CreaListaInput { + nome: string; + stato: string; + voci?: ListaVoceInput[]; + // Liste personali/di gruppo già esistenti, agganciate direttamente come sotto-lista. + sottoListeIds?: string[]; + // Liste pubbliche approvate del catalogo: vengono forkate al volo dal backend in + // nuove liste bozza personali, poi agganciate come le altre. + sottoListeModelloIds?: string[]; + tipoEventoId?: string; +} + export interface AggiornaListaInput { nome?: string; + // Permette di promuovere una lista bozza/privata a pubblico (o viceversa) anche in + // edit, non solo alla creazione. + stato?: string; voci?: ListaVoceInput[]; + sottoListeIds?: string[]; + sottoListeModelloIds?: string[]; + // undefined = non toccare il campo, null = rimuovi il tipo evento associato. + tipoEventoId?: string | null; } @Injectable({ providedIn: 'root' }) @@ -37,8 +80,26 @@ export class ListeApiService { return this.http.get(`${environment.magazzinoApiBaseUrl}/liste`); } - creaListaVuota(nome: string): Observable { - return this.http.post(`${environment.magazzinoApiBaseUrl}/liste`, { nome }); + // Catalogo pubblico: liste già approvate dalla moderazione, usabili come base + // ("usa come base") per crearne di nuove. Filtro opzionale per tipo evento. + getListePubbliche(tipoEventoId?: string): Observable { + const params = tipoEventoId ? { tipoEventoId } : undefined; + return this.http.get(`${environment.magazzinoApiBaseUrl}/liste/pubbliche`, { params }); + } + + // Dettaglio pubblico per singolo id (deep-link diretto al catalogo). + getListaPubblica(id: string): Observable { + return this.http.get(`${environment.magazzinoApiBaseUrl}/liste/pubbliche/${id}`); + } + + creaLista(input: CreaListaInput): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/liste`, input); + } + + // Fork: crea una nuova lista bozza personale a partire da una lista pubblica + // approvata ("usa come base"). + creaListaDaFork(listaOrigineId: string): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/liste/da-fork/${listaOrigineId}`, {}); } aggiornaLista(id: string, input: AggiornaListaInput): Observable { diff --git a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.css b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.css index a7879ae..24b1f4e 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.css +++ b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.css @@ -1,18 +1,28 @@ -.liste-list__form { +/* Header replicato 1:1 da scouthub-attivita-fe (pagina "Le mie attività"): + stesso font, stessa dimensione testo, stessa spaziatura. */ +.page { + max-width: 1080px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +.page-header { display: flex; - align-items: flex-start; - gap: var(--space-4); - margin-bottom: var(--space-2); + align-items: baseline; + justify-content: space-between; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 12px; } -.liste-list__field { - flex: 1; +.page-title { + font-size: 30px; + margin: 0; + font-weight: 700; } -.liste-list__campo-errore { - margin: 4px 0 0; - font-size: 12px; - color: var(--color-accent-800); +.page-title--sm { + font-size: 28px; } .liste-list__error { @@ -20,21 +30,95 @@ margin-bottom: var(--space-3); } -.liste-list__elenco { - margin-top: var(--space-5); +.lista { + display: flex; + flex-direction: column; + gap: 14px; } -.liste-list__voce { - text-decoration: none; - transition: box-shadow 0.15s ease, transform 0.15s ease; +.riga { + background: var(--color-surface); + border: 1px solid var(--color-divider); + border-radius: 14px; + padding: 18px 20px; + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; } -.liste-list__voce:hover { - box-shadow: var(--shadow-sm); +.riga-info { + flex: 1; + min-width: 220px; } -.liste-list__voci-count { +.riga-titolo { + cursor: pointer; + font-weight: 700; + font-size: 17px; color: var(--color-text); - opacity: 0.6; + text-decoration: none; +} + +.riga-data { + font-size: 13px; + color: color-mix(in srgb, var(--color-text) 55%, transparent); + 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-nota { + display: block; + margin-top: 6px; + font-size: 13px; + font-weight: 600; + color: var(--color-accent-800); +} + +.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-divider); +} + +.riga-modifica { + cursor: pointer; + padding: 9px 14px; + border-radius: 8px; + border: 1px solid var(--color-divider); + font-weight: 600; + font-size: 14px; +} + +.empty-state { + text-align: center; + padding: 80px 20px; + color: color-mix(in srgb, var(--color-text) 55%, transparent); +} + +.empty-icon { + font-size: 40px; + margin-bottom: 12px; +} + +.empty-title { + font-size: 18px; + font-weight: 600; } diff --git a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.html b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.html index 2714e5c..de8f31a 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.html +++ b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.html @@ -1,43 +1,71 @@ -
-

Le tue liste

-

Liste materiale della tua organizzazione.

- -
-
- - - @if (nome.hasError('required')) { -

Il nome della lista è obbligatorio.

- } @else if (nome.hasError('minlength')) { -

Il nome deve avere almeno 3 caratteri.

- } @else if (nome.hasError('maxlength')) { -

Il nome non può superare i 100 caratteri.

- } -
- - -
- - @if (creazioneErrore(); as message) { - - } +
+ @if (loading()) {

Caricamento liste…

} @else if (loadError(); as message) { - } @else if (liste().length === 0) { -

Non hai ancora nessuna lista. Creane una qui sopra.

- } @else { -
+ } @else if (liste().length > 0) { +
@for (lista of liste(); track lista.id) { - -
{{ lista.nome }}
- {{ lista.voci.length }} voci -
+
+
+ {{ lista.nome }} +
Creata il {{ dataCreazioneFmt(lista) }} · {{ lista.voci.length }} voci
+
+ {{ statoNome(lista.stato) }} +
+ @if (haVociInAttesa(lista)) { +
+ ⚠️ materiali in attesa di conferma +
+ } + @if (inAttesaApprovazione(lista)) { +
+ ⏳ in attesa di approvazione +
+ } @else if (rifiutata(lista)) { +
+ ❌ proposta rifiutata +
+ } +
+ @if (puoCambiareStato(lista)) { +
+ @for (stato of stati; track stato.id) { +
+ {{ stato.nome }} +
+ } +
+ } @else { +
+ 🔒 solo il creatore può cambiare lo stato +
+ } +
Modifica
+
}
+ } @else { +
+
📋
+
{{ titoloVuoto() }}
+
} -
+ diff --git a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.spec.ts b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.spec.ts index 532edfd..ee8a082 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.spec.ts +++ b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter, Router } from '@angular/router'; +import { ActivatedRoute, provideRouter } from '@angular/router'; import { of, throwError } from 'rxjs'; import { Lista, ListeApiService } from '../liste-api.service'; @@ -8,29 +8,73 @@ import { ListeList } from './liste-list'; describe('ListeList', () => { let component: ListeList; let fixture: ComponentFixture; - let listeApi: { getListe: ReturnType; creaListaVuota: ReturnType }; - let router: Router; + let listeApi: { getListe: ReturnType }; const liste: Lista[] = [ - { id: 'lista-1', nome: 'Campo estivo 2026', orgId: 'org-1', creataIl: '2026-01-01', voci: [] }, + { + id: 'lista-1', + nome: 'Campo estivo 2026', + orgId: 'org-1', + stato: 'bozza', + statoModerazione: null, + tipoEventoId: null, + parentId: null, + creataIl: '2026-01-01', + creataDaMe: true, + voci: [], + sottoListe: [] + }, { id: 'lista-2', nome: 'Uscita di un giorno', orgId: 'org-1', + stato: 'pubblico', + statoModerazione: 'approvato', + tipoEventoId: null, + parentId: null, creataIl: '2026-01-02', - voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }] + creataDaMe: true, + voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }], + sottoListe: [] + }, + { + id: 'lista-3', + nome: 'Materiale sede', + orgId: 'org-1', + stato: 'gruppo', + statoModerazione: null, + tipoEventoId: null, + parentId: null, + creataIl: '2026-01-03', + creataDaMe: false, + voci: [], + sottoListe: [] + }, + { + id: 'lista-4', + nome: 'Materiale creato da me per il gruppo', + orgId: 'org-1', + stato: 'gruppo', + statoModerazione: null, + tipoEventoId: null, + parentId: null, + creataIl: '2026-01-04', + creataDaMe: true, + voci: [], + sottoListe: [] } ]; - async function setup(): Promise { + async function setup(scope: 'mie' | 'gruppo' = 'mie'): Promise { await TestBed.configureTestingModule({ imports: [ListeList], - providers: [provideRouter([]), { provide: ListeApiService, useValue: listeApi }] + providers: [ + provideRouter([]), + { provide: ListeApiService, useValue: listeApi }, + { provide: ActivatedRoute, useValue: { snapshot: { data: { scope } } } } + ] }).compileComponents(); - router = TestBed.inject(Router); - vi.spyOn(router, 'navigate').mockResolvedValue(true); - fixture = TestBed.createComponent(ListeList); component = fixture.componentInstance; fixture.detectChanges(); @@ -38,73 +82,98 @@ describe('ListeList', () => { fixture.detectChanges(); } - it('mostra l\'elenco delle liste dopo il caricamento', async () => { - listeApi = { getListe: vi.fn().mockReturnValue(of(liste)), creaListaVuota: vi.fn() }; + it('mostra le liste personali più le proprie liste di gruppo, escludendo quelle di gruppo altrui', async () => { + listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) }; await setup(); expect(component.loading()).toBe(false); - expect(component.liste()).toEqual(liste); + expect(component.liste()).toEqual(liste.filter((l) => l.stato !== 'gruppo' || l.creataDaMe)); const compiled = fixture.nativeElement as HTMLElement; - const links = compiled.querySelectorAll('.liste-list__elenco a'); - expect(links.length).toBe(2); + const links = compiled.querySelectorAll('.riga-titolo'); + expect(links.length).toBe(3); expect(links[0].getAttribute('href')).toBe('/liste/lista-1'); + expect(links[2].getAttribute('href')).toBe('/liste/lista-4'); + }); + + it('con scope "gruppo" mostra tutte le liste di gruppo dell\'org, comprese quelle create da altri', async () => { + listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) }; + + await setup('gruppo'); + + expect(component.liste()).toEqual(liste.filter((l) => l.stato === 'gruppo')); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Le nostre liste'); + const links = compiled.querySelectorAll('.riga-titolo'); + expect(links.length).toBe(2); + expect(links[0].getAttribute('href')).toBe('/liste/lista-3'); + expect(links[1].getAttribute('href')).toBe('/liste/lista-4'); + }); + + it('con scope "gruppo" nasconde il selettore di stato per le liste altrui e lo mostra per le proprie', async () => { + listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) }; + + await setup('gruppo'); + + expect(component.puoCambiareStato(liste[2])).toBe(false); // lista-3, altrui + expect(component.puoCambiareStato(liste[3])).toBe(true); // lista-4, mia + + const compiled = fixture.nativeElement as HTMLElement; + const righe = compiled.querySelectorAll('.riga'); + expect(righe[0].querySelector('.stato-options')).toBeNull(); + expect(righe[0].textContent).toContain('solo il creatore può cambiare lo stato'); + expect(righe[1].querySelector('.stato-options')).not.toBeNull(); + }); + + it('non invia la richiesta di cambio stato se non si è proprietari della lista di gruppo', async () => { + listeApi = { + getListe: vi.fn().mockReturnValue(of(liste)), + aggiornaLista: vi.fn() + } as unknown as typeof listeApi; + + await setup('gruppo'); + + component.cambiaStato(liste[2], { id: 'privato', nome: 'Privato' }); + + expect((listeApi as unknown as { aggiornaLista: ReturnType }).aggiornaLista).not.toHaveBeenCalled(); + }); + + it('mostra un bottone che porta alla pagina di creazione di una nuova lista', async () => { + listeApi = { getListe: vi.fn().mockReturnValue(of([])) }; + + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + const link = compiled.querySelector('.page-header a'); + expect(link?.getAttribute('href')).toBe('/liste/nuova'); + }); + + it('con scope "gruppo" il bottone "+ Nuova lista" forza lo stato di partenza a gruppo', async () => { + listeApi = { getListe: vi.fn().mockReturnValue(of([])) }; + + await setup('gruppo'); + + const compiled = fixture.nativeElement as HTMLElement; + const link = compiled.querySelector('.page-header a'); + expect(link?.getAttribute('href')).toBe('/liste/nuova?stato=gruppo'); }); it('mostra un messaggio se non ci sono liste', async () => { - listeApi = { getListe: vi.fn().mockReturnValue(of([])), creaListaVuota: vi.fn() }; + listeApi = { getListe: vi.fn().mockReturnValue(of([])) }; await setup(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Non hai ancora nessuna lista'); + expect(compiled.textContent).toContain('Non hai ancora creato nessuna lista'); }); it('mostra un messaggio di errore se il caricamento fallisce', async () => { - listeApi = { getListe: vi.fn().mockReturnValue(throwError(() => new Error('network error'))), creaListaVuota: vi.fn() }; + listeApi = { getListe: vi.fn().mockReturnValue(throwError(() => new Error('network error'))) }; await setup(); - expect(component.loadError()).toBe('Impossibile caricare le tue liste. Riprova più tardi.'); - }); - - describe('creazione di una nuova lista vuota', () => { - beforeEach(async () => { - listeApi = { getListe: vi.fn().mockReturnValue(of([])), creaListaVuota: vi.fn() }; - await setup(); - }); - - it('non chiama l\'API se il nome non è valido e marca il controllo come touched', async () => { - component.nome.setValue(''); - - await component.creaLista(); - - expect(listeApi.creaListaVuota).not.toHaveBeenCalled(); - expect(component.nome.touched).toBe(true); - }); - - it('crea la lista e reindirizza al suo editor', async () => { - component.nome.setValue('Campo estivo 2026'); - listeApi.creaListaVuota.mockReturnValue( - of({ id: 'lista-nuova', nome: 'Campo estivo 2026', orgId: 'org-1', creataIl: '2026-01-01', voci: [] }) - ); - - await component.creaLista(); - - expect(listeApi.creaListaVuota).toHaveBeenCalledWith('Campo estivo 2026'); - expect(router.navigate).toHaveBeenCalledWith(['/liste', 'lista-nuova']); - expect(component.creazioneInCorso()).toBe(false); - }); - - it('mostra un messaggio di errore se la creazione fallisce', async () => { - component.nome.setValue('Campo estivo 2026'); - listeApi.creaListaVuota.mockReturnValue(throwError(() => new Error('server error'))); - - await component.creaLista(); - - expect(component.creazioneErrore()).toBe('Impossibile creare la lista. Riprova più tardi.'); - expect(router.navigate).not.toHaveBeenCalled(); - }); + expect(component.loadError()).toBe('Impossibile caricare le liste. Riprova più tardi.'); }); }); diff --git a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.ts b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.ts index b592e2b..55d284c 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.ts +++ b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.ts @@ -1,81 +1,125 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; -import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { Router, RouterLink } from '@angular/router'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { firstValueFrom } from 'rxjs'; import { Lista, ListeApiService } from '../liste-api.service'; +import { STATI_LISTA, StatoLista, statoListaStyle } from '../lista.model'; + +// Route data['scope']: 'mie' (default, /liste) mostra le liste personali dell'utente +// più le proprie liste 'gruppo' (create da lui, anche se visibili a tutta l'org); +// 'gruppo' (/liste/gruppo) mostra tutte le liste 'gruppo' della sua organizzazione, +// comprese quelle create da altri membri, modificabili da chiunque ne faccia parte. +// Una lista 'gruppo' creata dall'utente compare quindi in entrambe le viste. GET +// /liste restituisce già l'unione di tutto (vedi liste.service.ts::listMieListe), qui +// si filtra solo client-side. +type Scope = 'mie' | 'gruppo'; @Component({ selector: 'app-liste-list', - imports: [ - ReactiveFormsModule, - RouterLink, - MatButtonModule, - MatFormFieldModule, - MatInputModule, - MatProgressSpinnerModule - ], + imports: [RouterLink], templateUrl: './liste-list.html', styleUrl: './liste-list.css' }) export class ListeList implements OnInit { private readonly listeApi = inject(ListeApiService); private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + + private readonly scope: Scope = (this.route.snapshot.data['scope'] as Scope | undefined) ?? 'mie'; + + readonly titolo = computed(() => (this.scope === 'gruppo' ? 'Le nostre liste' : 'Le mie liste')); + readonly titoloVuoto = computed(() => + this.scope === 'gruppo' + ? 'La tua organizzazione non ha ancora creato nessuna lista di gruppo' + : 'Non hai ancora creato nessuna lista' + ); + + // Da "Le nostre liste" la nuova lista nasce già 'gruppo' (vedi nuova-lista.ts, che + // legge questo query param per bloccare la scelta dello stato in creazione). + readonly nuovaListaQueryParams: Record = this.scope === 'gruppo' ? { stato: 'gruppo' } : {}; readonly loading = signal(true); readonly loadError = signal(null); readonly liste = signal([]); - - readonly nome = new FormControl('', { - nonNullable: true, - validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)] - }); - - readonly creazioneInCorso = signal(false); - readonly creazioneErrore = signal(null); + readonly stati = STATI_LISTA; async ngOnInit(): Promise { await this.caricaListe(); } - async creaLista(): Promise { - if (this.creazioneInCorso()) { - return; - } - - if (this.nome.invalid) { - this.nome.markAsTouched(); - return; - } - - this.creazioneErrore.set(null); - this.creazioneInCorso.set(true); - - try { - const lista = await firstValueFrom(this.listeApi.creaListaVuota(this.nome.value.trim())); - this.creazioneInCorso.set(false); - await this.router.navigate(['/liste', lista.id]); - } catch { - this.creazioneInCorso.set(false); - this.creazioneErrore.set('Impossibile creare la lista. Riprova più tardi.'); - } - } - private async caricaListe(): Promise { this.loading.set(true); this.loadError.set(null); try { const liste = await firstValueFrom(this.listeApi.getListe()); - this.liste.set(liste); + this.liste.set( + liste.filter((l) => (this.scope === 'gruppo' ? l.stato === 'gruppo' : l.stato !== 'gruppo' || l.creataDaMe)) + ); } catch { - this.loadError.set('Impossibile caricare le tue liste. Riprova più tardi.'); + this.loadError.set('Impossibile caricare le liste. Riprova più tardi.'); } finally { this.loading.set(false); } } + + dataCreazioneFmt(lista: Lista): string { + return new Intl.DateTimeFormat('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' }).format( + new Date(lista.creataIl), + ); + } + + modifica(lista: Lista): void { + this.router.navigate(['/liste', lista.id]); + } + + // Le liste pubbliche approvate hanno una vista dedicata (stessa usata dal catalogo, + // GET /liste/pubbliche/:id); le altre non sono raggiungibili da quella route (404), + // quindi il click sul nome apre comunque l'editor. + dettaglioLink(lista: Lista): string[] { + return lista.stato === 'pubblico' && lista.statoModerazione === 'approvato' + ? ['/lista-modello', lista.id] + : ['/liste', lista.id]; + } + + statoStile(idStato: string) { + return statoListaStyle(idStato); + } + + statoNome(idStato: string): string { + return STATI_LISTA.find((s) => s.id === idStato)?.nome ?? idStato; + } + + haVociInAttesa(lista: Lista): boolean { + return lista.voci.some((v) => v.inAttesaConferma); + } + + inAttesaApprovazione(lista: Lista): boolean { + return lista.stato === 'pubblico' && lista.statoModerazione === 'proposto'; + } + + rifiutata(lista: Lista): boolean { + return lista.stato === 'pubblico' && lista.statoModerazione === 'rifiutato'; + } + + isStatoAttivo(lista: Lista, stato: StatoLista): boolean { + return lista.stato === stato.id; + } + + // Il contenuto di una lista 'gruppo' è collaborativo, ma solo chi l'ha creata può + // cambiarne lo stato (es. toglierla dal gruppo): altrimenti un membro qualsiasi + // potrebbe far sparire ad altri una lista condivisa. Vedi liste.service.ts::aggiornaLista. + puoCambiareStato(lista: Lista): boolean { + return lista.stato !== 'gruppo' || lista.creataDaMe; + } + + cambiaStato(lista: Lista, stato: StatoLista): void { + if (lista.stato === stato.id || !this.puoCambiareStato(lista)) { + return; + } + this.listeApi.aggiornaLista(lista.id, { stato: stato.id }).subscribe({ + next: () => this.caricaListe(), + error: () => this.loadError.set('Impossibile cambiare stato alla lista. Riprova più tardi.') + }); + } } diff --git a/scouthub-magazzino-fe/src/app/liste/liste.routes.ts b/scouthub-magazzino-fe/src/app/liste/liste.routes.ts index 12189a9..3f41545 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste.routes.ts +++ b/scouthub-magazzino-fe/src/app/liste/liste.routes.ts @@ -6,11 +6,25 @@ export const LISTE_ROUTES: Routes = [ { path: '', loadComponent: () => import('./liste-list/liste-list').then((m) => m.ListeList), + canActivate: [requireAuthGuard], + data: { scope: 'mie' } + }, + { + // Dichiarata prima di ':id' (come 'nuova' sotto) per non essere intercettata dal + // catch-all di modifica. + path: 'gruppo', + loadComponent: () => import('./liste-list/liste-list').then((m) => m.ListeList), + canActivate: [requireAuthGuard], + data: { scope: 'gruppo' } + }, + { + path: 'nuova', + loadComponent: () => import('./nuova-lista/nuova-lista').then((m) => m.NuovaLista), canActivate: [requireAuthGuard] }, { path: ':id', - loadComponent: () => import('./lista-editor/lista-editor').then((m) => m.ListaEditor), + loadComponent: () => import('./nuova-lista/nuova-lista').then((m) => m.NuovaLista), canActivate: [requireAuthGuard] } ]; diff --git a/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.css b/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.css new file mode 100644 index 0000000..23f8cef --- /dev/null +++ b/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.css @@ -0,0 +1,328 @@ +/* Stile del wizard replicato 1:1 da scouthub-attivita-fe (pagina "Nuova attività"): + stesso font, stessa dimensione testo, stessa spaziatura. */ +.page { + max-width: 1080px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +.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-accent); + color: #fff; +} + +.step-content { + display: flex; + flex-direction: column; + gap: 16px; +} + +.errore-inline { + color: var(--color-accent-800); + font-size: 13px; +} + +.field { + font-weight: 700; + font-size: 14px; +} + +.text-input { + display: block; + width: 100%; + box-sizing: border-box; + margin-top: 6px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--color-divider); + font-size: 16px; + font-family: inherit; + background: var(--color-surface); + color: var(--color-text); +} + +.text-input--con-clear { + padding-right: 34px; +} + +.text-input--sm { + margin-top: 0; + padding: 10px 12px; + border-radius: 8px; + font-size: 14px; +} + +.stato-choices { + display: flex; + gap: 8px; + margin-top: 6px; + flex-wrap: wrap; +} + +.stato-choice { + cursor: pointer; + padding: 9px 16px; + border-radius: 8px; + font-weight: 700; + font-size: 14px; + border: 1px solid var(--color-divider); +} + +.stato-choices--disabilitato .stato-choice { + cursor: not-allowed; + opacity: 0.5; +} + +.search-field { + position: relative; +} + +.search-input-wrap { + position: relative; +} + +.input-clear-btn { + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + cursor: pointer; + padding: 2px 6px; + color: var(--color-accent-800); +} + +.suggestions { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: var(--color-surface); + border: 1px solid var(--color-divider); + border-radius: 10px; + box-shadow: var(--shadow-md); + 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: color-mix(in srgb, var(--color-text) 55%, transparent); +} + +.suggestion-item { + padding: 10px 16px; + cursor: pointer; + font-size: 15px; + font-weight: 400; +} + +.suggestion-item:hover { + background: var(--color-accent-100); +} + +.nessun-risultato { + color: var(--color-text); + opacity: 0.7; + font-size: 14px; + margin: 0; +} + +.link-proponi { + cursor: pointer; + color: var(--color-accent); + opacity: 1; + font-weight: 600; + margin-left: 4px; +} + +.proponi-form { + background: var(--color-surface); + border: 1px solid var(--color-divider); + border-radius: 10px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.proponi-form__titolo { + font-weight: 700; + font-size: 14px; +} + +.field--sm { + font-size: 13px; + font-weight: 600; + color: color-mix(in srgb, var(--color-text) 70%, transparent); +} + +.proponi-form__azioni { + display: flex; + gap: 8px; +} + +.voce-attesa { + margin-left: 6px; + cursor: help; +} + +.voci-tabella { + display: flex; + flex-direction: column; + gap: 8px; +} + +.voce-riga { + display: flex; + align-items: center; + gap: var(--space-3); + background: var(--color-surface); + border: 1px solid var(--color-divider); + border-radius: 10px; + padding: 10px 14px; +} + +.voce-nome { + flex: 1; + font-size: 14px; +} + +.voce-quantita { + width: 4.5rem; + flex: none; +} + +.voce-unita { + font-size: 13px; + color: color-mix(in srgb, var(--color-text) 55%, transparent); +} + +.azione { + cursor: pointer; + padding: 2px 8px; +} + +.azione--danger { + color: var(--color-accent-800); +} + +.riepilogo { + background: var(--color-surface); + border: 1px solid var(--color-divider); + border-radius: 14px; + padding: 20px; +} + +.riepilogo-nome { + font-weight: 700; + font-size: 19px; + margin-bottom: 10px; +} + +.riepilogo-stato { + display: inline-block; + padding: 3px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + border: 1px solid; + margin-bottom: 14px; +} + +.riepilogo-voci { + margin: 16px 0 0; + padding-left: 0; + list-style: none; + font-size: 15px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.riepilogo-voce { + display: flex; + align-items: center; + gap: 10px; + cursor: default; +} + +.riepilogo-voce input[type='checkbox'] { + cursor: default; +} + +.riepilogo-voci-vuoto { + font-size: 14px; + opacity: 0.7; +} + +.wizard-nav { + display: flex; + justify-content: space-between; + margin-top: 28px; +} + +.btn-primary { + cursor: pointer; + display: inline-block; + padding: 11px 18px; + border-radius: 10px; + background: var(--color-accent); + color: #fff; + font-weight: 700; + border: none; + font-family: inherit; + font-size: 15px; +} + +.btn-primary:hover { + background: var(--color-accent-600); +} + +.btn-secondary { + cursor: pointer; + display: inline-block; + padding: 9px 14px; + border-radius: 8px; + border: 1px solid var(--color-divider); + font-weight: 600; + font-size: 14px; + background: transparent; + color: var(--color-text); + font-family: inherit; +} diff --git a/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.html b/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.html new file mode 100644 index 0000000..e826f4b --- /dev/null +++ b/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.html @@ -0,0 +1,306 @@ +
+

{{ titolo() }}

+ + @if (caricamentoModifica()) { +

Caricamento lista…

+ } @else if (caricamentoErrore(); as message) { + + } @else { + +
+ @for (label of stepLabels; track $index) { +
+ {{ $index + 1 }}. {{ label }} +
+ } +
+ + @if (step() === 0) { +
+ + + + + @if (tipoEventoRicercaInCorso()) { +

Ricerca in corso…

+ } @else if (tipoEventoAppenaProposto()) { +

Tipo evento "{{ tipoEventoTesto() }}" proposto, in attesa di approvazione.

+ } @else if (mostraProponiTipoEvento()) { +

+ Nessun tipo evento esistente corrisponde. + + {{ tipoEventoProponiInCorso() ? 'Invio in corso…' : '+ Proponi "' + tipoEventoTesto() + '" come nuovo tipo evento' }} + +

+ } + @if (tipoEventoProponiErrore()) { +
{{ tipoEventoProponiErrore() }}
+ } +
+ } + + @if (step() === 1) { +
+ + + @if (ricercaInCorso()) { +

Ricerca in corso…

+ } @else if (nessunRisultato() && !mostraFormProposta()) { +

+ Nessun risultato per "{{ ricerca() }}". + + Proponi "{{ ricerca() }}" come nuovo materiale +

+ } + + @if (mostraFormProposta()) { +
+
Proponi "{{ ricerca() }}" come nuovo materiale
+ + @if (categoriaRicercaInCorso()) { +

Ricerca categoria in corso…

+ } @else if (categoriaAppenaProposta()) { +

Categoria "{{ proponiCategoria() }}" proposta, in attesa di approvazione.

+ } @else if (mostraProponiCategoria()) { +

+ Nessuna categoria esistente corrisponde. + + {{ proponiCategoriaInCorso() ? 'Invio in corso…' : '+ Proponi "' + proponiCategoria() + '" come nuova categoria' }} + +

+ } + @if (proponiCategoriaErrore()) { +
{{ proponiCategoriaErrore() }}
+ } + + @if (proponiErrore()) { +
{{ proponiErrore() }}
+ } +
+
Annulla
+
+ {{ proponiInCorso() ? 'Invio in corso…' : 'Proponi materiale' }} +
+
+
+ } + + @if (voci().length > 0) { +
+ @for (voce of voci(); track voce.materialeId) { +
+ + {{ voce.nome }} + @if (voce.inAttesaConferma) { + ⚠️ + } + + + {{ voce.unitaMisura }} + +
+ } +
+ } + + @if (sottoListeSelezionate().length > 0) { +
+ @for (sottoLista of sottoListeSelezionate(); track sottoLista.id) { +
+ + {{ sottoLista.nome }} + {{ sottoLista.origine === 'modello' ? '(dal catalogo)' : '(tua lista)' }} + + +
+ } +
+ } +
+ } + + @if (step() === 2) { + + } + +
+
+ Indietro +
+ @if (isLastStep()) { +
+ {{ salvataggioInCorso() ? (editingId() ? 'Salvataggio in corso…' : 'Creazione in corso…') : (editingId() ? 'Salva modifiche' : 'Salva lista') }} +
+ } @else { +
Avanti
+ } +
+ } +
diff --git a/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.spec.ts b/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.spec.ts new file mode 100644 index 0000000..edafa50 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.spec.ts @@ -0,0 +1,422 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { MaterialiApiService } from '../../catalogo/materiali-api.service'; +import { Lista, ListeApiService } from '../liste-api.service'; +import { NuovaLista } from './nuova-lista'; + +describe('NuovaLista', () => { + let component: NuovaLista; + let fixture: ComponentFixture; + let listeApi: { + creaLista: ReturnType; + aggiornaLista: ReturnType; + getListe: ReturnType; + getListePubbliche: ReturnType; + }; + let materialiApi: { getMateriali: ReturnType; proponiMateriale: ReturnType }; + let router: Router; + + async function attendiDebounce(): Promise { + await new Promise((resolve) => setTimeout(resolve, 350)); + fixture.detectChanges(); + } + + async function setup(idParam: string | null = null, queryParam: string | null = null): Promise { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NuovaLista], + providers: [ + provideRouter([]), + { provide: ListeApiService, useValue: listeApi }, + { provide: MaterialiApiService, useValue: materialiApi }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { paramMap: { get: () => idParam }, queryParamMap: { get: () => queryParam } } + } + } + ] + }).compileComponents(); + + router = TestBed.inject(Router); + vi.spyOn(router, 'navigate').mockResolvedValue(true); + + fixture = TestBed.createComponent(NuovaLista); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + beforeEach(async () => { + listeApi = { + creaLista: vi.fn(), + aggiornaLista: vi.fn(), + getListe: vi.fn().mockReturnValue(of([])), + getListePubbliche: vi.fn().mockReturnValue(of([])) + }; + materialiApi = { getMateriali: vi.fn().mockReturnValue(of([])), proponiMateriale: vi.fn() }; + await setup(); + }); + + it('parte dallo step 0 con stato iniziale Bozza', () => { + expect(component.step()).toBe(0); + expect(component.stato().id).toBe('bozza'); + }); + + it('non avanza dallo step 0 se il nome è troppo corto', () => { + component.nome.set('ab'); + + component.nextStep(); + + expect(component.step()).toBe(0); + expect(component.nomeErrore()).toBe('Il nome della lista deve avere almeno 3 caratteri.'); + }); + + it('avanza tra gli step e permette di cambiare stato', () => { + component.nome.set('Campo estivo 2026'); + component.nextStep(); + expect(component.step()).toBe(1); + + component.nextStep(); + expect(component.step()).toBe(2); + + component.prevStep(); + expect(component.step()).toBe(1); + }); + + it('aggiunge e rimuove materiali dalla lista in costruzione', () => { + component.aggiungiMateriale({ id: 'm-1', nome: 'Corda', categoria: 'x', unitaMisura: 'pz' }); + + expect(component.voci()).toEqual([{ materialeId: 'm-1', nome: 'Corda', unitaMisura: 'pz', quantita: 1 }]); + + component.rimuoviVoce('m-1'); + + expect(component.voci()).toEqual([]); + }); + + it('crea la lista con nome, stato e voci selezionate, poi reindirizza a "Le mie liste"', async () => { + component.nome.set('Campo estivo 2026'); + component.selectStato({ id: 'pubblico', nome: 'Pubblico' }); + component.aggiungiMateriale({ id: 'm-1', nome: 'Corda', categoria: 'x', unitaMisura: 'pz' }); + + listeApi.creaLista.mockReturnValue( + of({ id: 'lista-nuova', nome: 'Campo estivo 2026', orgId: 'org-1', stato: 'pubblico', creataIl: '2026-01-01', voci: [] }) + ); + + await component.salva(); + + expect(listeApi.creaLista).toHaveBeenCalledWith({ + nome: 'Campo estivo 2026', + stato: 'pubblico', + voci: [{ materialeId: 'm-1', quantita: 1 }], + sottoListeIds: [], + sottoListeModelloIds: [] + }); + expect(router.navigate).toHaveBeenCalledWith(['/liste']); + }); + + it('crea una lista di gruppo e reindirizza a "Le nostre liste"', async () => { + component.nome.set('Materiale sede'); + component.selectStato({ id: 'gruppo', nome: 'Gruppo' }); + + listeApi.creaLista.mockReturnValue( + of({ id: 'lista-nuova', nome: 'Materiale sede', orgId: 'org-1', stato: 'gruppo', creataIl: '2026-01-01', voci: [] }) + ); + + await component.salva(); + + expect(router.navigate).toHaveBeenCalledWith(['/liste/gruppo']); + }); + + it('mostra un messaggio di errore se la creazione fallisce', async () => { + component.nome.set('Campo estivo 2026'); + listeApi.creaLista.mockReturnValue(throwError(() => new Error('server error'))); + + await component.salva(); + + expect(component.salvataggioErrore()).toBe('Impossibile creare la lista. Riprova più tardi.'); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + describe('ricerca materiale via backend (debounced)', () => { + it('chiama il backend con la query dopo il debounce, non ad ogni digitazione', async () => { + materialiApi.getMateriali.mockReturnValue(of([{ id: 'm-1', nome: 'Corda', categoria: 'x', unitaMisura: 'pz' }])); + + component.ricerca.set('cor'); + component.ricerca.set('cord'); + component.ricerca.set('corda'); + expect(materialiApi.getMateriali).not.toHaveBeenCalled(); + + await attendiDebounce(); + + expect(materialiApi.getMateriali).toHaveBeenCalledTimes(1); + expect(materialiApi.getMateriali).toHaveBeenCalledWith('corda'); + expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['m-1']); + }); + + it('esclude dai risultati i materiali già aggiunti alla lista', async () => { + component.aggiungiMateriale({ id: 'm-1', nome: 'Corda', categoria: 'x', unitaMisura: 'pz' }); + materialiApi.getMateriali.mockReturnValue( + of([ + { id: 'm-1', nome: 'Corda', categoria: 'x', unitaMisura: 'pz' }, + { id: 'm-2', nome: 'Corda sottile', categoria: 'x', unitaMisura: 'pz' } + ]) + ); + + component.ricerca.set('corda'); + await attendiDebounce(); + + expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['m-2']); + }); + + it('propone un nuovo materiale se non esiste, e lo aggiunge come "in attesa di conferma"', async () => { + materialiApi.getMateriali.mockReturnValue(of([])); + component.ricerca.set('bussola'); + await attendiDebounce(); + + expect(component.nessunRisultato()).toBe(true); + + component.apriFormProposta(); + component.proponiCategoria.set('Orientamento'); + component.proponiUnitaMisura.set('pz'); + materialiApi.proponiMateriale.mockReturnValue( + of({ + id: 'm-nuovo', + nome: 'bussola', + categoria: 'Orientamento', + unitaMisura: 'pz', + stato: 'proposto', + propostoDaOrgId: 'org-1', + creatoIl: '2026-01-01' + }) + ); + + await component.confermaProposta(); + + expect(materialiApi.proponiMateriale).toHaveBeenCalledWith({ + nome: 'bussola', + categoria: 'Orientamento', + unitaMisura: 'pz' + }); + expect(component.voci()).toEqual([ + { materialeId: 'm-nuovo', nome: 'bussola', unitaMisura: 'pz', quantita: 1, inAttesaConferma: true } + ]); + expect(component.mostraFormProposta()).toBe(false); + }); + + it('non propone il materiale se categoria o unità di misura mancano', async () => { + component.ricerca.set('bussola'); + component.apriFormProposta(); + + await component.confermaProposta(); + + expect(materialiApi.proponiMateriale).not.toHaveBeenCalled(); + expect(component.proponiErrore()).toBe('Nome, categoria e unità di misura sono obbligatori.'); + }); + }); + + describe('ricerca unificata di materiale e liste (un solo campo, come "Cerca lista")', () => { + beforeEach(async () => { + listeApi.getListePubbliche.mockReturnValue( + of([{ id: 'lm-1', nome: 'Kit pronto soccorso', orgId: null, stato: 'pubblico', statoModerazione: 'approvato', tipoEventoId: null, parentId: null, creataIl: '2026-01-01', voci: [], sottoListe: [] }]) + ); + listeApi.getListe.mockReturnValue( + of([{ id: 'l-1', nome: 'Campo estivo 2025', orgId: null, stato: 'privato', statoModerazione: null, tipoEventoId: null, parentId: null, creataIl: '2025-01-01', voci: [], sottoListe: [] }]) + ); + await setup(); + materialiApi.getMateriali.mockReturnValue(of([{ id: 'm-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' }])); + }); + + it('raggruppa i suggerimenti per Materiale, Liste dal catalogo e Le tue liste', async () => { + component.ricerca.set('c'); + await attendiDebounce(); + + const gruppi = component.suggestionGroups(); + expect(gruppi.map((g) => g.label)).toEqual(['Materiale', 'Liste dal catalogo', 'Le tue liste']); + expect(gruppi[0].items).toEqual([{ id: 'm-1', nome: 'Corda (Attrezzatura)' }]); + expect(gruppi[1].items).toEqual([{ id: 'lm-1', nome: 'Kit pronto soccorso' }]); + expect(gruppi[2].items).toEqual([{ id: 'l-1', nome: 'Campo estivo 2025' }]); + }); + + it('selezionando un suggerimento di tipo materiale lo aggiunge alla lista', async () => { + component.ricerca.set('corda'); + await attendiDebounce(); + + component.selectSuggestion('materiale', 'm-1'); + + expect(component.voci().map((v) => v.materialeId)).toEqual(['m-1']); + expect(component.ricerca()).toBe(''); + }); + + it('selezionando un suggerimento di tipo lista dal catalogo lo aggiunge come sotto-lista', async () => { + component.ricerca.set('kit'); + await attendiDebounce(); + + component.selectSuggestion('listaModello', 'lm-1'); + + expect(component.sottoListeSelezionate()).toEqual([{ id: 'lm-1', nome: 'Kit pronto soccorso', origine: 'modello' }]); + }); + + it('selezionando una tua lista la aggiunge come sotto-lista', async () => { + component.ricerca.set('campo'); + await attendiDebounce(); + + component.selectSuggestion('listaPersonale', 'l-1'); + + expect(component.sottoListeSelezionate()).toEqual([{ id: 'l-1', nome: 'Campo estivo 2025', origine: 'personale' }]); + }); + }); + + describe('modalità modifica (route /liste/:id)', () => { + const listaEsistente: Lista = { + id: 'lista-1', + nome: 'Campo estivo 2026', + orgId: null, + stato: 'bozza', + statoModerazione: null, + tipoEventoId: null, + parentId: null, + creataIl: '2026-01-01', + creataDaMe: true, + voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }], + sottoListe: [] + }; + + beforeEach(async () => { + listeApi.getListe.mockReturnValue(of([listaEsistente])); + await setup('lista-1'); + }); + + it('precompila nome, stato e voci della lista esistente', () => { + expect(component.titolo()).toBe('Modifica lista'); + expect(component.nome()).toBe('Campo estivo 2026'); + expect(component.stato().id).toBe('bozza'); + expect(component.voci()).toEqual([{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }]); + expect(component.caricamentoErrore()).toBeNull(); + }); + + it('mostra un errore se la lista non viene trovata tra le mie liste', async () => { + listeApi.getListe.mockReturnValue(of([])); + await setup('lista-inesistente'); + + expect(component.caricamentoErrore()).toBe('Lista non trovata.'); + }); + + it('al salvataggio chiama aggiornaLista e torna a "Le mie liste"', async () => { + component.nome.set('Campo estivo 2026 - aggiornato'); + listeApi.aggiornaLista.mockReturnValue(of({ ...listaEsistente, nome: 'Campo estivo 2026 - aggiornato' })); + + await component.salva(); + + expect(listeApi.aggiornaLista).toHaveBeenCalledWith('lista-1', { + nome: 'Campo estivo 2026 - aggiornato', + stato: 'bozza', + voci: [{ materialeId: 'mat-1', quantita: 2 }], + sottoListeIds: [], + sottoListeModelloIds: [], + tipoEventoId: null + }); + expect(listeApi.creaLista).not.toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/liste']); + }); + + it('mostra un messaggio di errore se il salvataggio fallisce', async () => { + listeApi.aggiornaLista.mockReturnValue(throwError(() => new Error('server error'))); + + await component.salva(); + + expect(component.salvataggioErrore()).toBe('Impossibile salvare le modifiche. Riprova più tardi.'); + }); + }); + + describe('modalità modifica di una lista di gruppo non propria', () => { + const listaGruppoAltrui: Lista = { + id: 'lista-2', + nome: 'Materiale sede', + orgId: 'org-1', + stato: 'gruppo', + statoModerazione: null, + tipoEventoId: null, + parentId: null, + creataIl: '2026-01-01', + creataDaMe: false, + voci: [], + sottoListe: [] + }; + + beforeEach(async () => { + listeApi.getListe.mockReturnValue(of([listaGruppoAltrui])); + await setup('lista-2'); + }); + + it('non permette di cambiare lo stato', () => { + expect(component.puoModificareStato()).toBe(false); + + component.selectStato(component.stati.find((s) => s.id === 'privato')!); + + expect(component.stato().id).toBe('gruppo'); + }); + + it('nasconde il selettore di stato nel template mostrando una nota', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.stato-choices--disabilitato')).not.toBeNull(); + expect(compiled.textContent).toContain('solo il creatore può cambiare lo stato'); + }); + }); + + describe('creazione da "Le nostre liste" (?stato=gruppo)', () => { + beforeEach(async () => { + await setup(null, 'gruppo'); + }); + + it('precompila lo stato a "gruppo" e non permette di cambiarlo', () => { + expect(component.stato().id).toBe('gruppo'); + expect(component.puoModificareStato()).toBe(false); + + component.selectStato(component.stati.find((s) => s.id === 'bozza')!); + + expect(component.stato().id).toBe('gruppo'); + }); + + it('nasconde il selettore di stato nel template mostrando la nota di creazione da gruppo', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.stato-choices--disabilitato')).not.toBeNull(); + expect(compiled.textContent).toContain('le liste create da "Le nostre liste" sono sempre di gruppo'); + }); + + it('al salvataggio crea la lista con stato "gruppo"', async () => { + component.nome.set('Materiale sede'); + listeApi.creaLista.mockReturnValue( + of({ + id: 'lista-nuova', + nome: 'Materiale sede', + orgId: 'org-1', + stato: 'gruppo', + statoModerazione: null, + tipoEventoId: null, + parentId: null, + creataIl: '2026-01-01', + creataDaMe: true, + voci: [], + sottoListe: [] + }) + ); + + await component.salva(); + + expect(listeApi.creaLista).toHaveBeenCalledWith( + expect.objectContaining({ nome: 'Materiale sede', stato: 'gruppo' }) + ); + }); + }); + + describe('creazione normale (senza query param)', () => { + it('non forza alcuno stato: la scelta resta libera', () => { + expect(component.puoModificareStato()).toBe(true); + expect(component.stato().id).toBe('bozza'); + }); + }); +}); diff --git a/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.ts b/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.ts new file mode 100644 index 0000000..ec5db34 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/liste/nuova-lista/nuova-lista.ts @@ -0,0 +1,578 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { catchError, debounceTime, distinctUntilChanged, firstValueFrom, of, switchMap } from 'rxjs'; + +import { MaterialePubblico, MaterialiApiService } from '../../catalogo/materiali-api.service'; +import { TipoEvento, TipiEventoApiService } from '../../catalogo/tipi-evento-api.service'; +import { Categoria, CategorieApiService } from '../../tassonomie/categorie-api.service'; +import { Lista, ListaVoceInput, ListeApiService } from '../liste-api.service'; +import { STATI_LISTA, STATO_BOZZA, StatoLista, statoListaStyle } from '../lista.model'; + +const STEP_LABELS = ['Info base', 'Materiale', 'Riepilogo']; +const AUTOCOMPLETE_DEBOUNCE_MS = 300; + +interface VoceForm { + materialeId: string; + nome: string; + unitaMisura: string; + quantita: number; + inAttesaConferma?: boolean; +} + +interface SottoListaForm { + id: string; + nome: string; + origine: 'modello' | 'personale'; +} + +@Component({ + selector: 'app-nuova-lista', + imports: [], + templateUrl: './nuova-lista.html', + styleUrl: './nuova-lista.css' +}) +export class NuovaLista { + private readonly route = inject(ActivatedRoute); + private readonly listeApi = inject(ListeApiService); + private readonly materialiApi = inject(MaterialiApiService); + private readonly categorieApi = inject(CategorieApiService); + private readonly tipiEventoApi = inject(TipiEventoApiService); + private readonly router = inject(Router); + + // Presente solo su /liste/:id: stesso wizard di creazione, ma precompilato e con + // salva() che aggiorna la lista esistente invece di crearne una nuova. + readonly editingId = signal(null); + readonly caricamentoModifica = signal(false); + readonly caricamentoErrore = signal(null); + + readonly step = signal(0); + readonly stepLabels = STEP_LABELS; + readonly stati = STATI_LISTA; + readonly titolo = computed(() => (this.editingId() ? 'Modifica lista' : 'Nuova lista')); + + readonly nome = signal(''); + readonly stato = signal(STATO_BOZZA); + readonly voci = signal([]); + + // Tipo evento (opzionale, step 0): stesso pattern di autocomplete + proposta usato + // per categoria nel form materiale, ma qui la selezione è a valore singolo (un id) e + // non un semplice testo libero, perché tipoEventoId è una vera FK su Lista. + readonly tipoEventoTesto = signal(''); + readonly tipoEventoIdSelezionato = signal(null); + readonly tipoEventoRisultati = signal([]); + readonly tipoEventoRicercaInCorso = signal(false); + readonly tipoEventoAppenaProposto = signal(false); + readonly tipoEventoProponiInCorso = signal(false); + readonly tipoEventoProponiErrore = signal(null); + + readonly tipoEventoCorrispondeEsistente = computed(() => + this.tipoEventoRisultati().some((t) => t.nome.toLowerCase() === this.tipoEventoTesto().trim().toLowerCase()) + ); + + readonly mostraProponiTipoEvento = computed( + () => + !this.tipoEventoRicercaInCorso() && + !this.tipoEventoAppenaProposto() && + !this.tipoEventoIdSelezionato() && + !this.tipoEventoCorrispondeEsistente() && + this.tipoEventoTesto().trim().length > 0 + ); + + // Solo in modifica di una lista 'gruppo' non propria: il contenuto è collaborativo, + // ma cambiarne lo stato resta una decisione di chi l'ha creata (vedi + // liste.service.ts::aggiornaLista e liste-list.ts::puoCambiareStato). Sempre true in + // creazione o per qualunque altra lista. + readonly puoModificareStato = signal(true); + + readonly ricerca = signal(''); + readonly risultatiRicerca = signal([]); + readonly ricercaInCorso = signal(false); + + readonly mostraFormProposta = signal(false); + readonly proponiCategoria = signal(''); + readonly proponiUnitaMisura = signal(''); + readonly proponiInCorso = signal(false); + readonly proponiErrore = signal(null); + + // Autocomplete categoria nel form di proposta materiale: cerca tra le categorie + // già confermate; se il testo digitato non corrisponde a nessuna, offre un tasto + // per proporne una nuova (workflow separato da quello di proposta materiale, vedi + // categorie.service.ts::proponiCategoria — resta comunque una proposta 'da_approvare', + // il materiale può essere proposto subito con quel testo come categoria). + readonly categorieRicerca = signal([]); + readonly categoriaRicercaInCorso = signal(false); + readonly categoriaAppenaProposta = signal(false); + readonly proponiCategoriaInCorso = signal(false); + readonly proponiCategoriaErrore = signal(null); + + readonly categoriaCorrispondeEsistente = computed(() => + this.categorieRicerca().some((c) => c.nome.toLowerCase() === this.proponiCategoria().trim().toLowerCase()) + ); + + readonly mostraProponiCategoria = computed( + () => + !this.categoriaRicercaInCorso() && + !this.categoriaAppenaProposta() && + !this.categoriaCorrispondeEsistente() && + this.proponiCategoria().trim().length > 0 + ); + + readonly nomeErrore = signal(null); + readonly salvataggioInCorso = signal(false); + readonly salvataggioErrore = signal(null); + + // Candidate sotto-lista: caricate una volta sola (nessuna ricerca lato server per + // liste modello/liste personali) e filtrate client-side in base al testo digitato. + readonly catalogoListe = signal([]); + readonly mieListe = signal([]); + readonly sottoListeSelezionate = signal([]); + + readonly isLastStep = computed(() => this.step() === 2); + + // Un'unica ricerca (come in "Cerca lista"): il testo digitato filtra in parallelo + // materiali (via backend, debounced) e liste (già in memoria, filtro client-side + // istantaneo), mostrati come gruppi distinti nello stesso dropdown di suggerimenti. + readonly risultatiSottoListaModello = computed(() => { + const termine = this.ricerca().trim().toLowerCase(); + if (!termine) { + return []; + } + const selezionatiIds = new Set(this.sottoListeSelezionate().map((s) => s.id)); + return this.catalogoListe().filter( + (l) => l.sottoListe.length === 0 && !selezionatiIds.has(l.id) && l.nome.toLowerCase().includes(termine) + ); + }); + + readonly risultatiSottoListaPersonale = computed(() => { + const termine = this.ricerca().trim().toLowerCase(); + if (!termine) { + return []; + } + // Una lista personale (non di gruppo) può agganciare solo proprie liste + // personali: le liste di gruppo restano escluse dal picker in quel caso. + const soloPersonali = this.stato().id !== 'gruppo'; + const selezionatiIds = new Set(this.sottoListeSelezionate().map((s) => s.id)); + return this.mieListe().filter( + (l) => + l.id !== this.editingId() && + l.sottoListe.length === 0 && + !selezionatiIds.has(l.id) && + (!soloPersonali || l.stato !== 'gruppo') && + l.nome.toLowerCase().includes(termine) + ); + }); + + readonly suggestionGroups = computed(() => { + const gruppi: { label: string; tipo: 'materiale' | 'listaModello' | 'listaPersonale'; items: { id: string; nome: string }[] }[] = []; + if (this.risultatiRicerca().length > 0) { + gruppi.push({ + label: 'Materiale', + tipo: 'materiale', + items: this.risultatiRicerca().map((m) => ({ id: m.id, nome: `${m.nome} (${m.categoria})` })) + }); + } + if (this.risultatiSottoListaModello().length > 0) { + gruppi.push({ + label: 'Liste dal catalogo', + tipo: 'listaModello', + items: this.risultatiSottoListaModello().map((l) => ({ id: l.id, nome: l.nome })) + }); + } + if (this.risultatiSottoListaPersonale().length > 0) { + gruppi.push({ + label: 'Le tue liste', + tipo: 'listaPersonale', + items: this.risultatiSottoListaPersonale().map((l) => ({ id: l.id, nome: l.nome })) + }); + } + return gruppi; + }); + + readonly nessunRisultato = computed( + () => + !this.ricercaInCorso() && + this.ricerca().trim().length > 0 && + this.risultatiRicerca().length === 0 && + this.risultatiSottoListaModello().length === 0 && + this.risultatiSottoListaPersonale().length === 0 + ); + + constructor() { + firstValueFrom(this.listeApi.getListePubbliche().pipe(catchError(() => of([])))).then((liste) => + this.catalogoListe.set(liste) + ); + + // Usata solo per risolvere il nome del tipo evento già assegnato quando si apre + // una lista esistente in modifica (vedi caricaPerModifica): l'API restituisce solo + // tipoEventoId, non il nome. + const tipiEventoConosciuti = firstValueFrom(this.tipiEventoApi.getTipiEvento().pipe(catchError(() => of([])))); + + const idParam = this.route.snapshot.paramMap.get('id'); + if (idParam) { + this.editingId.set(idParam); + this.caricamentoModifica.set(true); + } else { + // Arrivando da "Le nostre liste" (?stato=gruppo, vedi liste-list.ts), la nuova + // lista nasce già di gruppo e lo stato non è scelta dell'utente: stessa nota di + // sola lettura mostrata in modifica per una lista di gruppo non propria. + const statoParam = this.route.snapshot.queryParamMap.get('stato'); + const statoForzato = this.stati.find((s) => s.id === statoParam); + if (statoForzato) { + this.stato.set(statoForzato); + this.puoModificareStato.set(false); + } + } + firstValueFrom(this.listeApi.getListe().pipe(catchError(() => of([])))).then(async (liste) => { + this.mieListe.set(liste); + if (idParam) { + this.caricaPerModifica(idParam, liste, await tipiEventoConosciuti); + } + }); + + toObservable(this.ricerca) + .pipe( + debounceTime(AUTOCOMPLETE_DEBOUNCE_MS), + distinctUntilChanged(), + switchMap((query) => { + const termine = query.trim(); + if (!termine) { + this.ricercaInCorso.set(false); + return of([]); + } + this.ricercaInCorso.set(true); + return this.materialiApi.getMateriali(termine).pipe(catchError(() => of([]))); + }), + takeUntilDestroyed() + ) + .subscribe((risultati) => { + this.ricercaInCorso.set(false); + const idGiaPresenti = new Set(this.voci().map((v) => v.materialeId)); + this.risultatiRicerca.set(risultati.filter((m) => !idGiaPresenti.has(m.id))); + }); + + toObservable(this.proponiCategoria) + .pipe( + debounceTime(AUTOCOMPLETE_DEBOUNCE_MS), + distinctUntilChanged(), + switchMap((query) => { + const termine = query.trim(); + if (!termine) { + this.categoriaRicercaInCorso.set(false); + return of([]); + } + this.categoriaRicercaInCorso.set(true); + return this.categorieApi.getCategoriePubbliche(termine).pipe(catchError(() => of([]))); + }), + takeUntilDestroyed() + ) + .subscribe((risultati) => { + this.categoriaRicercaInCorso.set(false); + this.categorieRicerca.set(risultati); + this.categoriaAppenaProposta.set(false); + }); + + toObservable(this.tipoEventoTesto) + .pipe( + debounceTime(AUTOCOMPLETE_DEBOUNCE_MS), + distinctUntilChanged(), + switchMap((query) => { + const termine = query.trim(); + if (!termine) { + this.tipoEventoRicercaInCorso.set(false); + return of([]); + } + this.tipoEventoRicercaInCorso.set(true); + return this.tipiEventoApi.getTipiEvento(termine).pipe(catchError(() => of([]))); + }), + takeUntilDestroyed() + ) + .subscribe((risultati) => { + this.tipoEventoRicercaInCorso.set(false); + this.tipoEventoRisultati.set(risultati); + this.tipoEventoAppenaProposto.set(false); + }); + } + + private caricaPerModifica(id: string, mieListe: Lista[], tipiEventoConosciuti: TipoEvento[]): void { + const lista = mieListe.find((l) => l.id === id); + if (!lista) { + this.caricamentoErrore.set('Lista non trovata.'); + this.caricamentoModifica.set(false); + return; + } + + this.nome.set(lista.nome); + this.stato.set(this.stati.find((s) => s.id === lista.stato) ?? this.stati[0]); + this.puoModificareStato.set(lista.stato !== 'gruppo' || lista.creataDaMe); + this.voci.set(lista.voci.map((v) => ({ ...v }))); + this.sottoListeSelezionate.set( + lista.sottoListe.map((sl) => ({ id: sl.id, nome: sl.nome, origine: 'personale' as const })) + ); + if (lista.tipoEventoId) { + this.tipoEventoIdSelezionato.set(lista.tipoEventoId); + this.tipoEventoTesto.set(tipiEventoConosciuti.find((t) => t.id === lista.tipoEventoId)?.nome ?? ''); + } + this.caricamentoModifica.set(false); + } + + statoStile(idStato: string) { + return statoListaStyle(idStato); + } + + selectStato(stato: StatoLista): void { + if (!this.puoModificareStato()) { + return; + } + this.stato.set(stato); + } + + aggiungiMateriale(materiale: MaterialePubblico): void { + if (this.voci().some((v) => v.materialeId === materiale.id)) { + return; + } + this.voci.update((voci) => [ + ...voci, + { materialeId: materiale.id, nome: materiale.nome, unitaMisura: materiale.unitaMisura, quantita: 1 } + ]); + this.ricerca.set(''); + this.mostraFormProposta.set(false); + } + + rimuoviVoce(materialeId: string): void { + this.voci.update((voci) => voci.filter((v) => v.materialeId !== materialeId)); + } + + modificaQuantita(materialeId: string, valore: string): void { + const parsed = Number.parseInt(valore, 10); + const quantita = Number.isInteger(parsed) && parsed > 0 ? parsed : 1; + this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, quantita } : v))); + } + + aggiungiSottoListaModello(lista: Lista): void { + if (this.sottoListeSelezionate().some((s) => s.id === lista.id)) { + return; + } + this.sottoListeSelezionate.update((s) => [...s, { id: lista.id, nome: lista.nome, origine: 'modello' }]); + this.ricerca.set(''); + } + + aggiungiSottoListaPersonale(lista: Lista): void { + if (this.sottoListeSelezionate().some((s) => s.id === lista.id)) { + return; + } + this.sottoListeSelezionate.update((s) => [...s, { id: lista.id, nome: lista.nome, origine: 'personale' }]); + this.ricerca.set(''); + } + + selectSuggestion(tipo: 'materiale' | 'listaModello' | 'listaPersonale', id: string): void { + if (tipo === 'materiale') { + const materiale = this.risultatiRicerca().find((m) => m.id === id); + if (materiale) { + this.aggiungiMateriale(materiale); + } + return; + } + if (tipo === 'listaModello') { + const lista = this.risultatiSottoListaModello().find((l) => l.id === id); + if (lista) { + this.aggiungiSottoListaModello(lista); + } + return; + } + const lista = this.risultatiSottoListaPersonale().find((l) => l.id === id); + if (lista) { + this.aggiungiSottoListaPersonale(lista); + } + } + + rimuoviSottoLista(id: string): void { + this.sottoListeSelezionate.update((s) => s.filter((sl) => sl.id !== id)); + } + + apriFormProposta(): void { + this.proponiErrore.set(null); + this.proponiCategoria.set(''); + this.proponiUnitaMisura.set(''); + this.categorieRicerca.set([]); + this.categoriaAppenaProposta.set(false); + this.proponiCategoriaErrore.set(null); + this.mostraFormProposta.set(true); + } + + annullaProposta(): void { + this.mostraFormProposta.set(false); + } + + selezionaCategoria(categoria: Categoria): void { + this.proponiCategoria.set(categoria.nome); + this.categorieRicerca.set([categoria]); + } + + async proponiNuovaCategoria(): Promise { + const nome = this.proponiCategoria().trim(); + if (nome.length === 0 || this.proponiCategoriaInCorso()) { + return; + } + + this.proponiCategoriaErrore.set(null); + this.proponiCategoriaInCorso.set(true); + + try { + await firstValueFrom(this.categorieApi.proponiCategoria(nome)); + this.proponiCategoriaInCorso.set(false); + this.categoriaAppenaProposta.set(true); + } catch { + this.proponiCategoriaInCorso.set(false); + this.proponiCategoriaErrore.set('Impossibile proporre la categoria. Riprova più tardi.'); + } + } + + async confermaProposta(): Promise { + const nome = this.ricerca().trim(); + const categoria = this.proponiCategoria().trim(); + const unitaMisura = this.proponiUnitaMisura().trim(); + + if (nome.length === 0 || categoria.length === 0 || unitaMisura.length === 0) { + this.proponiErrore.set('Nome, categoria e unità di misura sono obbligatori.'); + return; + } + + this.proponiErrore.set(null); + this.proponiInCorso.set(true); + + try { + const proposta = await firstValueFrom(this.materialiApi.proponiMateriale({ nome, categoria, unitaMisura })); + this.voci.update((voci) => [ + ...voci, + { + materialeId: proposta.id, + nome: proposta.nome, + unitaMisura: proposta.unitaMisura, + quantita: 1, + inAttesaConferma: true + } + ]); + this.proponiInCorso.set(false); + this.mostraFormProposta.set(false); + this.ricerca.set(''); + } catch { + this.proponiInCorso.set(false); + this.proponiErrore.set('Impossibile proporre il materiale. Riprova più tardi.'); + } + } + + onTipoEventoInput(valore: string): void { + this.tipoEventoTesto.set(valore); + this.tipoEventoIdSelezionato.set(null); + } + + selezionaTipoEvento(tipoEvento: TipoEvento): void { + this.tipoEventoTesto.set(tipoEvento.nome); + this.tipoEventoIdSelezionato.set(tipoEvento.id); + this.tipoEventoRisultati.set([tipoEvento]); + } + + rimuoviTipoEvento(): void { + this.tipoEventoTesto.set(''); + this.tipoEventoIdSelezionato.set(null); + this.tipoEventoRisultati.set([]); + this.tipoEventoAppenaProposto.set(false); + } + + async proponiNuovoTipoEvento(): Promise { + const nome = this.tipoEventoTesto().trim(); + if (nome.length === 0 || this.tipoEventoProponiInCorso()) { + return; + } + + this.tipoEventoProponiErrore.set(null); + this.tipoEventoProponiInCorso.set(true); + + try { + const proposto = await firstValueFrom(this.tipiEventoApi.proponiTipoEvento(nome)); + this.tipoEventoIdSelezionato.set(proposto.id); + this.tipoEventoProponiInCorso.set(false); + this.tipoEventoAppenaProposto.set(true); + } catch { + this.tipoEventoProponiInCorso.set(false); + this.tipoEventoProponiErrore.set('Impossibile proporre il tipo evento. Riprova più tardi.'); + } + } + + nextStep(): void { + if (this.step() === 0) { + if (this.nome().trim().length < 3) { + this.nomeErrore.set('Il nome della lista deve avere almeno 3 caratteri.'); + return; + } + this.nomeErrore.set(null); + } + this.step.update((s) => Math.min(2, s + 1)); + } + + prevStep(): void { + this.step.update((s) => Math.max(0, s - 1)); + } + + setStep(n: number): void { + if (n === 0 || this.nome().trim().length >= 3) { + this.step.set(n); + } + } + + async salva(): Promise { + if (this.salvataggioInCorso()) { + return; + } + + this.salvataggioErrore.set(null); + this.salvataggioInCorso.set(true); + + const voci: ListaVoceInput[] = this.voci().map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })); + const sottoListeIds = this.sottoListeSelezionate() + .filter((s) => s.origine === 'personale') + .map((s) => s.id); + const sottoListeModelloIds = this.sottoListeSelezionate() + .filter((s) => s.origine === 'modello') + .map((s) => s.id); + + const editingId = this.editingId(); + const tipoEventoId = this.tipoEventoIdSelezionato(); + + try { + if (editingId) { + await firstValueFrom( + this.listeApi.aggiornaLista(editingId, { + nome: this.nome().trim(), + stato: this.stato().id, + voci, + sottoListeIds, + sottoListeModelloIds, + tipoEventoId + }) + ); + this.salvataggioInCorso.set(false); + await this.router.navigate(this.stato().id === 'gruppo' ? ['/liste/gruppo'] : ['/liste']); + } else { + const lista = await firstValueFrom( + this.listeApi.creaLista({ + nome: this.nome().trim(), + stato: this.stato().id, + voci, + sottoListeIds, + sottoListeModelloIds, + ...(tipoEventoId ? { tipoEventoId } : {}) + }) + ); + this.salvataggioInCorso.set(false); + await this.router.navigate(lista.stato === 'gruppo' ? ['/liste/gruppo'] : ['/liste']); + } + } catch { + this.salvataggioInCorso.set(false); + this.salvataggioErrore.set( + editingId ? 'Impossibile salvare le modifiche. Riprova più tardi.' : 'Impossibile creare la lista. Riprova più tardi.' + ); + } + } +} diff --git a/scouthub-magazzino-fe/src/app/magazzino/magazzino.css b/scouthub-magazzino-fe/src/app/magazzino/magazzino.css index 3de74b5..35cc90a 100644 --- a/scouthub-magazzino-fe/src/app/magazzino/magazzino.css +++ b/scouthub-magazzino-fe/src/app/magazzino/magazzino.css @@ -1,11 +1,123 @@ -.magazzino__header { - margin-bottom: var(--space-2); +/* Header replicato 1:1 dalla pagina "Le mie liste" (liste-list): stesso font, + stessa dimensione testo, stesso pulsante di azione primaria. */ +.page-header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 12px; +} + +.page-title { + font-size: 30px; + margin: 0; + font-weight: 700; +} + +.page-title--sm { + font-size: 28px; +} + +.magazzino__tabs { + display: flex; + width: 100%; + margin-bottom: var(--space-4); +} + +.magazzino__tabs .seg-opt { + flex: 1; + justify-content: center; } .magazzino__error { color: var(--color-accent-800); } +.magazzino__alfabeto { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: var(--space-4); +} + +.magazzino__lettera { + min-width: 28px; + padding: 4px 6px; + border: 1px solid var(--color-divider); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.magazzino__lettera:hover { + background: color-mix(in srgb, var(--color-text) 7%, transparent); +} + +.magazzino__lettera--attiva { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; +} + +.magazzino__contenuto--con-form { + /* padding, non margin: i margini verticali tra fratelli si "collassano" in + CSS (max dei due, non la somma), quindi lo spazio non aumentava nonostante + il form sotto avesse già margin-bottom. Il padding non collassa mai. */ + padding-top: var(--space-6); +} + +.magazzino__posizione-card { + gap: var(--space-3); +} + +.magazzino__posizione-lista { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.magazzino__posizione-voce { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) 0; + border-top: 1px solid var(--color-divider); + cursor: pointer; +} + +.magazzino__posizione-lista li:first-child { + border-top: none; +} + +.magazzino__posizione-voce-info { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.magazzino__posizione-voce-nome { + font-weight: 600; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.magazzino__posizione-voce-qta { + font-size: 12px; + opacity: 0.6; + flex-shrink: 0; +} + .magazzino__form { padding: var(--space-4); margin-bottom: var(--space-5); diff --git a/scouthub-magazzino-fe/src/app/magazzino/magazzino.html b/scouthub-magazzino-fe/src/app/magazzino/magazzino.html index d733b2f..faccc46 100644 --- a/scouthub-magazzino-fe/src/app/magazzino/magazzino.html +++ b/scouthub-magazzino-fe/src/app/magazzino/magazzino.html @@ -1,14 +1,25 @@
-
-
-

Giacenze di magazzino

-

Quantità, stato e posizione dei materiali della tua organizzazione.

-
+ +
+ + +
+ @if (loading()) {

Caricamento magazzino…

} @else if (loadError(); as message) { @@ -44,9 +55,6 @@ } @else if (nessunRisultato()) {

Nessun materiale trovato per "{{ ricerca.value }}". - - Proponi un nuovo materiale -

} } @@ -103,36 +111,79 @@ @if (voci().length === 0) {

Nessuna voce in magazzino.

} @else { - - - - - - - - - - - - - @for (voce of voci(); track voce.id) { - - - - - - - - +
+ @if (vista() === 'lista') { +
+ @for (lettera of lettereDisponibili(); track lettera) { + + } +
+ + @if (vociFiltrate().length === 0) { +

Nessun materiale che inizia per "{{ letteraSelezionata() }}".

+ } @else { +
MaterialeQuantitàStatoPosizioneNote
{{ voce.materialeNome }}{{ voce.quantitaPosseduta }} - - {{ voce.stato === 'buono' ? 'Buono' : voce.stato === 'da_riparare' ? 'Da riparare' : 'Mancante' }} - - {{ voce.posizione ?? '—' }}{{ voce.note ?? '—' }} - -
+ + + + + + + + + + + + @for (voce of vociFiltrate(); track voce.id) { + + + + + + + + + } + +
MaterialeQuantitàStatoPosizioneNote
{{ voce.materialeNome }}{{ voce.quantitaPosseduta }} + + {{ statoLabel(voce.stato) }} + + {{ voce.posizione ?? '—' }}{{ voce.note ?? '—' }} + +
} - - + } @else { +
+ @for (gruppo of gruppiPerPosizione(); track gruppo.posizione) { +
+
{{ gruppo.posizione }}
+
{{ gruppo.voci.length }} material{{ gruppo.voci.length === 1 ? 'e' : 'i' }}
+
    + @for (voce of gruppo.voci; track voce.id) { +
  • +
    + {{ voce.materialeNome }} + × {{ voce.quantitaPosseduta }} +
    + + {{ statoLabel(voce.stato) }} + +
  • + } +
+
+ } +
+ } +
} }
diff --git a/scouthub-magazzino-fe/src/app/magazzino/magazzino.spec.ts b/scouthub-magazzino-fe/src/app/magazzino/magazzino.spec.ts index faafdcb..38f7a2d 100644 --- a/scouthub-magazzino-fe/src/app/magazzino/magazzino.spec.ts +++ b/scouthub-magazzino-fe/src/app/magazzino/magazzino.spec.ts @@ -133,16 +133,16 @@ describe('Magazzino', () => { expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['mat-3']); }); - it('mostra il link per proporre un materiale se la ricerca non trova nulla', () => { + it('mostra il messaggio di nessun risultato se la ricerca non trova nulla', () => { component.ricerca.setValue('materiale inesistente'); fixture.detectChanges(); expect(component.nessunRisultato()).toBe(true); const compiled = fixture.nativeElement as HTMLElement; - const link = compiled.querySelector('.magazzino__nessun-risultato a') as HTMLAnchorElement; - expect(link).toBeTruthy(); - expect(link.getAttribute('href')).toBe('/proponi-materiale?nome=materiale%20inesistente'); + expect(compiled.querySelector('.magazzino__nessun-risultato')?.textContent).toContain( + 'materiale inesistente', + ); }); it('seleziona un materiale dal catalogo e lo mostra come scelto', () => { diff --git a/scouthub-magazzino-fe/src/app/magazzino/magazzino.ts b/scouthub-magazzino-fe/src/app/magazzino/magazzino.ts index b3d0fbd..03e56ea 100644 --- a/scouthub-magazzino-fe/src/app/magazzino/magazzino.ts +++ b/scouthub-magazzino-fe/src/app/magazzino/magazzino.ts @@ -6,7 +6,6 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatTableModule } from '@angular/material/table'; -import { RouterLink } from '@angular/router'; import { firstValueFrom } from 'rxjs'; import { MaterialePubblico, MaterialiApiService } from '../catalogo/materiali-api.service'; @@ -34,11 +33,19 @@ const STATI: OpzioneStato[] = [ { value: 'mancante', label: 'Mancante' } ]; +const SENZA_POSIZIONE = 'Senza posizione'; + +type Vista = 'lista' | 'posizione'; + +interface GruppoPosizione { + posizione: string; + voci: MagazzinoVoce[]; +} + @Component({ selector: 'app-magazzino', imports: [ ReactiveFormsModule, - RouterLink, MatButtonModule, MatFormFieldModule, MatInputModule, @@ -55,11 +62,60 @@ export class Magazzino implements OnInit { readonly displayedColumns = ['materiale', 'quantita', 'stato', 'posizione', 'note', 'azioni']; readonly stati = STATI; + readonly vista = signal('lista'); + readonly loading = signal(true); readonly loadError = signal(null); readonly voci = signal([]); readonly materialiCatalogo = signal([]); + // Vista "per lista": barra sopra la tabella con solo le iniziali dei + // materiali effettivamente presenti, per filtrare in base al nome. Una + // seconda volta sulla stessa lettera rimuove il filtro. + readonly letteraSelezionata = signal(null); + + readonly lettereDisponibili = computed(() => { + const lettere = new Set(); + for (const voce of this.voci()) { + const lettera = voce.materialeNome.trim().charAt(0).toUpperCase(); + if (lettera) { + lettere.add(lettera); + } + } + return Array.from(lettere).sort((a, b) => a.localeCompare(b, 'it')); + }); + + readonly vociFiltrate = computed(() => { + const lettera = this.letteraSelezionata(); + if (!lettera) { + return this.voci(); + } + return this.voci().filter((voce) => voce.materialeNome.trim().toUpperCase().startsWith(lettera)); + }); + + // Vista "per posizione": stesse voci raggruppate per il campo libero + // `posizione`, con le voci senza posizione raccolte in un gruppo a parte + // e sempre mostrato per ultimo. + readonly gruppiPerPosizione = computed(() => { + const gruppi = new Map(); + for (const voce of this.voci()) { + const chiave = voce.posizione?.trim() || SENZA_POSIZIONE; + const vociGruppo = gruppi.get(chiave); + if (vociGruppo) { + vociGruppo.push(voce); + } else { + gruppi.set(chiave, [voce]); + } + } + + const chiavi = Array.from(gruppi.keys()).sort((a, b) => { + if (a === SENZA_POSIZIONE) return 1; + if (b === SENZA_POSIZIONE) return -1; + return a.localeCompare(b, 'it'); + }); + return chiavi.map((posizione) => ({ posizione, voci: gruppi.get(posizione)! })); + }); + readonly formAperto = signal(false); readonly voceInModificaId = signal(null); readonly materialeSelezionato = signal(null); @@ -126,6 +182,14 @@ export class Magazzino implements OnInit { this.formAperto.set(false); } + statoLabel(stato: StatoMagazzinoVoce): string { + return this.stati.find((opzione) => opzione.value === stato)?.label ?? stato; + } + + selezionaLettera(lettera: string): void { + this.letteraSelezionata.set(this.letteraSelezionata() === lettera ? null : lettera); + } + selezionaMateriale(materiale: MaterialePubblico): void { this.materialeSelezionato.set({ id: materiale.id, nome: materiale.nome }); this.materialeErrore.set(null); diff --git a/scouthub-magazzino-fe/src/app/moderazione/moderazione.css b/scouthub-magazzino-fe/src/app/moderazione/moderazione.css deleted file mode 100644 index 6da4c1e..0000000 --- a/scouthub-magazzino-fe/src/app/moderazione/moderazione.css +++ /dev/null @@ -1,17 +0,0 @@ -.moderazione__elenco { - max-width: 640px; -} - -.moderazione__proposta { - max-width: none; -} - -.moderazione__data { - opacity: 0.6; -} - -.moderazione__error { - color: var(--color-accent-800); - font-size: 12px; - margin: var(--space-2) 0 0; -} diff --git a/scouthub-magazzino-fe/src/app/moderazione/moderazione.html b/scouthub-magazzino-fe/src/app/moderazione/moderazione.html deleted file mode 100644 index f48a7ea..0000000 --- a/scouthub-magazzino-fe/src/app/moderazione/moderazione.html +++ /dev/null @@ -1,44 +0,0 @@ -
-

Moderazione catalogo

-

Materiali proposti dalle organizzazioni, in attesa di approvazione.

- - @if (loading()) { -

Caricamento proposte…

- } @else if (loadError(); as message) { - - } @else if (proposte().length === 0) { -

Nessuna proposta in attesa di approvazione.

- } @else { -
- @for (proposta of proposte(); track proposta.id) { -
-
{{ proposta.categoria }} · {{ proposta.unitaMisura }}
-
{{ proposta.nome }}
-

Proposto il {{ proposta.creatoIl | slice: 0 : 10 }}

- -
- - -
- @if (decisioneErroreId() === proposta.id) { - - } -
- } -
- } -
diff --git a/scouthub-magazzino-fe/src/app/moderazione/moderazione.spec.ts b/scouthub-magazzino-fe/src/app/moderazione/moderazione.spec.ts deleted file mode 100644 index 58018c7..0000000 --- a/scouthub-magazzino-fe/src/app/moderazione/moderazione.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { of, throwError } from 'rxjs'; - -import { MaterialeProposta } from '../catalogo/materiali-api.service'; -import { MaterialiModerazioneApiService } from './materiali-moderazione-api.service'; -import { Moderazione } from './moderazione'; - -describe('Moderazione', () => { - let component: Moderazione; - let fixture: ComponentFixture; - let moderazioneApi: { getProposte: ReturnType; decidiProposta: ReturnType }; - - const proposte: MaterialeProposta[] = [ - { - id: 'prop-1', - nome: 'Fune da bucato', - categoria: 'Campeggio', - unitaMisura: 'pz', - stato: 'proposto', - propostoDaOrgId: 'org-1', - creatoIl: '2026-01-01T00:00:00.000Z' - }, - { - id: 'prop-2', - nome: 'Piccone', - categoria: 'Attrezzatura', - unitaMisura: 'pz', - stato: 'proposto', - propostoDaOrgId: 'org-2', - creatoIl: '2026-01-02T00:00:00.000Z' - } - ]; - - async function setup(): Promise { - await TestBed.configureTestingModule({ - imports: [Moderazione], - providers: [provideRouter([]), { provide: MaterialiModerazioneApiService, useValue: moderazioneApi }] - }).compileComponents(); - - fixture = TestBed.createComponent(Moderazione); - component = fixture.componentInstance; - fixture.detectChanges(); - await fixture.whenStable(); - fixture.detectChanges(); - } - - beforeEach(() => { - moderazioneApi = { getProposte: vi.fn().mockReturnValue(of(proposte)), decidiProposta: vi.fn() }; - }); - - it('mostra la lista delle proposte in attesa', async () => { - await setup(); - fixture.detectChanges(); - - expect(component.loading()).toBe(false); - expect(component.proposte()).toEqual(proposte); - - const compiled = fixture.nativeElement as HTMLElement; - const righe = compiled.querySelectorAll('.moderazione__tabella tbody tr'); - expect(righe.length).toBe(2); - expect(righe[0].textContent).toContain('Fune da bucato'); - }); - - it('mostra un messaggio se non ci sono proposte in attesa', async () => { - moderazioneApi.getProposte.mockReturnValue(of([])); - - await setup(); - - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Nessuna proposta in attesa'); - }); - - it('mostra un messaggio di errore se il caricamento fallisce', async () => { - moderazioneApi.getProposte.mockReturnValue(throwError(() => new Error('network error'))); - - await setup(); - - expect(component.loadError()).toBe('Impossibile caricare le proposte in attesa. Riprova più tardi.'); - }); - - describe('approvazione e rifiuto', () => { - beforeEach(async () => { - await setup(); - }); - - it('approva una proposta e la rimuove dalla lista', async () => { - const propostaApprovata: MaterialeProposta = { ...proposte[0], stato: 'approvato' }; - moderazioneApi.decidiProposta.mockReturnValue(of(propostaApprovata)); - - await component.decidi(proposte[0], 'approvato'); - - expect(moderazioneApi.decidiProposta).toHaveBeenCalledWith('prop-1', 'approvato'); - expect(component.proposte().map((p) => p.id)).toEqual(['prop-2']); - }); - - it('rifiuta una proposta e la rimuove dalla lista', async () => { - const propostaRifiutata: MaterialeProposta = { ...proposte[1], stato: 'rifiutato' }; - moderazioneApi.decidiProposta.mockReturnValue(of(propostaRifiutata)); - - await component.decidi(proposte[1], 'rifiutato'); - - expect(moderazioneApi.decidiProposta).toHaveBeenCalledWith('prop-2', 'rifiutato'); - expect(component.proposte().map((p) => p.id)).toEqual(['prop-1']); - }); - - it('mostra un messaggio di errore sulla proposta interessata se la decisione fallisce', async () => { - moderazioneApi.decidiProposta.mockReturnValue(throwError(() => new Error('server error'))); - - await component.decidi(proposte[0], 'approvato'); - - expect(component.decisioneErroreId()).toBe('prop-1'); - expect(component.proposte().map((p) => p.id)).toEqual(['prop-1', 'prop-2']); - - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Impossibile registrare la decisione'); - }); - }); -}); diff --git a/scouthub-magazzino-fe/src/app/moderazione/moderazione.ts b/scouthub-magazzino-fe/src/app/moderazione/moderazione.ts deleted file mode 100644 index 378ca1b..0000000 --- a/scouthub-magazzino-fe/src/app/moderazione/moderazione.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { SlicePipe } from '@angular/common'; -import { Component, OnInit, inject, signal } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatTableModule } from '@angular/material/table'; -import { firstValueFrom } from 'rxjs'; - -import { MaterialeProposta } from '../catalogo/materiali-api.service'; -import { DecisioneProposta, MaterialiModerazioneApiService } from './materiali-moderazione-api.service'; - -@Component({ - selector: 'app-moderazione', - imports: [SlicePipe, MatButtonModule, MatTableModule], - templateUrl: './moderazione.html', - styleUrl: './moderazione.css' -}) -export class Moderazione implements OnInit { - private readonly moderazioneApi = inject(MaterialiModerazioneApiService); - - readonly displayedColumns = ['nome', 'categoria', 'unitaMisura', 'creatoIl', 'azioni']; - - readonly loading = signal(true); - readonly loadError = signal(null); - readonly proposte = signal([]); - - readonly decisioneInCorsoId = signal(null); - readonly decisioneErroreId = signal(null); - - async ngOnInit(): Promise { - await this.caricaProposte(); - } - - async decidi(proposta: MaterialeProposta, decisione: DecisioneProposta): Promise { - if (this.decisioneInCorsoId()) { - return; - } - - this.decisioneErroreId.set(null); - this.decisioneInCorsoId.set(proposta.id); - - try { - await firstValueFrom(this.moderazioneApi.decidiProposta(proposta.id, decisione)); - this.proposte.update((proposte) => proposte.filter((p) => p.id !== proposta.id)); - } catch { - this.decisioneErroreId.set(proposta.id); - } finally { - this.decisioneInCorsoId.set(null); - } - } - - private async caricaProposte(): Promise { - this.loading.set(true); - this.loadError.set(null); - - try { - const proposte = await firstValueFrom(this.moderazioneApi.getProposte()); - this.proposte.set(proposte); - } catch { - this.loadError.set('Impossibile caricare le proposte in attesa. Riprova più tardi.'); - } finally { - this.loading.set(false); - } - } -} diff --git a/scouthub-magazzino-fe/src/app/shared/header/header.css b/scouthub-magazzino-fe/src/app/shared/header/header.css new file mode 100644 index 0000000..e923c39 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/shared/header/header.css @@ -0,0 +1,218 @@ +.header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + padding: 16px 24px; + background: var(--color-header); + border-bottom: 1px solid var(--color-divider); +} + +.back-link { + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--color-text); +} + +.back-arrow { + font-size: 20px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + color: var(--color-text); + text-decoration: none; +} + +.brand--right { + margin-left: auto; +} + +.logo { + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + background: var(--color-accent); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 18px; +} + +.header--back .logo { + width: 32px; + height: 32px; +} + +.brand-name { + font-family: var(--font-heading); + font-weight: var(--font-heading-weight); + font-size: 19px; + letter-spacing: 0.2px; +} + +.nav-items { + display: flex; + gap: 4px; + margin-left: auto; + flex-wrap: wrap; +} + +.nav-item { + cursor: pointer; + padding: 8px 14px; + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 15px; + color: var(--color-text); + background: transparent; + text-decoration: none; +} + +.nav-item--active { + color: var(--color-accent-700); + background: var(--color-accent-200); +} + +.auth-section { + display: flex; + align-items: center; + gap: 10px; +} + +.auth-name { + font-weight: 600; + font-size: 14px; + color: var(--color-text); +} + +.btn-secondary--sm { + 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: var(--radius-sm); + background: transparent; + font-size: 18px; + cursor: pointer; +} + +.campanellina:hover { + background: var(--color-accent-200); +} + +.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-divider); + 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-divider); +} + +.notifiche-segna-tutte { + cursor: pointer; + font-weight: 600; + font-size: 12px; + color: var(--color-accent-700); +} + +.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-divider); + font-size: 13px; + color: var(--color-text); +} + +.notifica-item:last-child { + border-bottom: none; +} + +.notifica-item:hover { + background: var(--color-accent-200); +} + +.notifica-item--non-letta { + font-weight: 600; + background: var(--color-accent-200); +} + +.notifica-item--non-letta::before { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-right: 8px; + border-radius: 999px; + background: var(--color-accent); +} diff --git a/scouthub-magazzino-fe/src/app/shared/header/header.html b/scouthub-magazzino-fe/src/app/shared/header/header.html new file mode 100644 index 0000000..9679eee --- /dev/null +++ b/scouthub-magazzino-fe/src/app/shared/header/header.html @@ -0,0 +1,75 @@ +@if (isDetailOrForm()) { +
+ +
+ + Scouthub Magazzino +
+
+} @else { +
+ + + Scouthub Magazzino + + +
+ @if (isAuthenticated()) { + @if (isModeratore()) { +
+ + @if (campanellinaAperta()) { +
+
+
+ Notifiche + @if (countNonLette() > 0) { + + Segna tutte come lette + + } +
+ @if (notifiche().length === 0) { +
Nessuna notifica
+ } @else { + @for (notifica of notifiche(); track notifica.id) { +
+ {{ notifica.messaggio }} +
+ } + } +
+ } +
+ } + {{ displayName() }} +
Esci
+ } @else { +
Accedi
+ } +
+
+} diff --git a/scouthub-magazzino-fe/src/app/shared/header/header.ts b/scouthub-magazzino-fe/src/app/shared/header/header.ts new file mode 100644 index 0000000..fd97b9e --- /dev/null +++ b/scouthub-magazzino-fe/src/app/shared/header/header.ts @@ -0,0 +1,180 @@ +import { Location } from '@angular/common'; +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], + templateUrl: './header.html', + styleUrl: './header.css' +}) +export class Header { + private readonly router = inject(Router); + 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([]); + 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; + }); + + readonly displayName = computed(() => { + this.keycloakEvent(); + const token = this.keycloak.tokenParsed; + return (token?.['name'] as string) ?? (token?.['preferred_username'] as string) ?? ''; + }); + + readonly isModeratore = computed(() => { + this.keycloakEvent(); + return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE); + }); + + // Pagine di dettaglio/form raggiunte da un elenco (non da un item di nav diretto): + // mostrano l'header con "Indietro" invece della barra di navigazione. + readonly isDetailOrForm = computed(() => { + const url = this.currentUrl(); + return url.startsWith('/lista-modello/') || (/^\/liste\/[^/]+$/.test(url) && url !== '/liste/gruppo'); + }); + + readonly navItems = computed(() => { + const items: NavItem[] = [ + { path: '/', label: 'Home' }, + { path: '/cerca-lista', label: 'Cerca' }, + { path: '/liste', label: 'Le mie liste' } + ]; + if (this.isAuthenticated()) { + items.push( + { path: '/liste/gruppo', label: 'Le nostre liste' }, + { path: '/magazzino', label: 'Magazzino' } + ); + } + 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); + }); + + // Le notifiche sono solo la coda di moderazione (materiali/categorie/tipi + // evento/liste proposti): il backend le espone solo a chi ha ruolo 'moderatore', + // quindi il polling parte/si ferma seguendo anche quello, non solo il login. + let intervalId: ReturnType | undefined; + effect(() => { + if (this.isAuthenticated() && this.isModeratore()) { + 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 { + if (path === '/') { + return this.currentUrl() === '/'; + } + // '/liste' e '/liste/gruppo' condividono prefisso: senza questa eccezione + // risulterebbero entrambe attive sulla pagina "Le nostre liste". + if (path === '/liste') { + return this.currentUrl().startsWith('/liste') && !this.currentUrl().startsWith('/liste/gruppo'); + } + return this.currentUrl().startsWith(path); + } + + back(): void { + this.location.back(); + } + + login(): void { + this.keycloak.login({ redirectUri: window.location.href }); + } + + 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); + }, + }); + } +} diff --git a/scouthub-magazzino-fe/src/app/tassonomie/categorie-api.service.ts b/scouthub-magazzino-fe/src/app/tassonomie/categorie-api.service.ts new file mode 100644 index 0000000..5fbd879 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/tassonomie/categorie-api.service.ts @@ -0,0 +1,53 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../environments/environment'; + +export type StatoCategoria = 'confermata' | 'da_approvare'; + +export interface Categoria { + id: string; + nome: string; + stato: StatoCategoria; + creatoDaOrgId: string | null; + creatoIl: string; +} + +@Injectable({ providedIn: 'root' }) +export class CategorieApiService { + private readonly http = inject(HttpClient); + + getCategorie(): Observable { + return this.http.get(`${environment.magazzinoApiBaseUrl}/categorie`); + } + + getCategoriePubbliche(nome?: string): Observable { + const params = nome ? new HttpParams().set('nome', nome) : undefined; + return this.http.get(`${environment.magazzinoApiBaseUrl}/categorie/pubbliche`, { params }); + } + + creaCategoria(nome: string): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/categorie`, { nome }); + } + + aggiornaCategoria(id: string, nome: string): Observable { + return this.http.put(`${environment.magazzinoApiBaseUrl}/categorie/${id}`, { nome }); + } + + eliminaCategoria(id: string): Observable { + return this.http.delete(`${environment.magazzinoApiBaseUrl}/categorie/${id}`); + } + + proponiCategoria(nome: string): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/categorie/proposte`, { nome }); + } + + approvaCategoria(id: string): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/categorie/${id}/approva`, {}); + } + + rifiutaCategoria(id: string): Observable { + return this.http.post(`${environment.magazzinoApiBaseUrl}/categorie/${id}/rifiuta`, {}); + } +} diff --git a/scouthub-magazzino-fe/src/app/tassonomie/liste-moderazione-api.service.ts b/scouthub-magazzino-fe/src/app/tassonomie/liste-moderazione-api.service.ts new file mode 100644 index 0000000..5b471e6 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/tassonomie/liste-moderazione-api.service.ts @@ -0,0 +1,23 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../environments/environment'; +import { Lista } from '../liste/liste-api.service'; + +export type DecisioneProposta = 'approvato' | 'rifiutato'; + +@Injectable({ providedIn: 'root' }) +export class ListeModerazioneApiService { + private readonly http = inject(HttpClient); + + getProposte(): Observable { + return this.http.get(`${environment.magazzinoApiBaseUrl}/liste/proposte`); + } + + decidiProposta(id: string, decisione: DecisioneProposta): Observable { + return this.http.patch(`${environment.magazzinoApiBaseUrl}/liste/proposte/${id}`, { + decisione + }); + } +} diff --git a/scouthub-magazzino-fe/src/app/moderazione/materiali-moderazione-api.service.ts b/scouthub-magazzino-fe/src/app/tassonomie/materiali-moderazione-api.service.ts similarity index 100% rename from scouthub-magazzino-fe/src/app/moderazione/materiali-moderazione-api.service.ts rename to scouthub-magazzino-fe/src/app/tassonomie/materiali-moderazione-api.service.ts diff --git a/scouthub-magazzino-fe/src/app/moderazione/require-moderatore.guard.spec.ts b/scouthub-magazzino-fe/src/app/tassonomie/require-moderatore.guard.spec.ts similarity index 100% rename from scouthub-magazzino-fe/src/app/moderazione/require-moderatore.guard.spec.ts rename to scouthub-magazzino-fe/src/app/tassonomie/require-moderatore.guard.spec.ts diff --git a/scouthub-magazzino-fe/src/app/moderazione/require-moderatore.guard.ts b/scouthub-magazzino-fe/src/app/tassonomie/require-moderatore.guard.ts similarity index 100% rename from scouthub-magazzino-fe/src/app/moderazione/require-moderatore.guard.ts rename to scouthub-magazzino-fe/src/app/tassonomie/require-moderatore.guard.ts diff --git a/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.css b/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.css new file mode 100644 index 0000000..4ae45f7 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.css @@ -0,0 +1,227 @@ +/* Stile replicato 1:1 dalla pagina "Tassonomie" di scouthub-attivita-fe (stessi nomi di + classe, stesso layout tabs/riga/form-card), con i soli token di colore rimappati su + quelli storici di questo progetto (--color-accent al posto di --color-primary, ecc.), + stesso pattern già usato in liste/nuova-lista/nuova-lista.css. */ + +.page { + max-width: 980px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +.page-header { + margin-bottom: 18px; +} + +.page-title { + font-size: 26px; + margin: 0 0 6px; + font-weight: 700; +} + +.page-title--sm { + font-size: 22px; +} + +.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-divider); + color: var(--color-text); +} + +.tab--attivo { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; +} + +.tab-intro { + font-size: 14px; + color: color-mix(in srgb, var(--color-text) 55%, transparent); + margin: 0 0 16px; +} + +.form-card { + background: var(--color-surface); + border: 1px solid var(--color-divider); + 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-divider); + 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-accent-800); + font-size: 13px; + margin-top: 10px; +} + +.lista { + display: flex; + flex-direction: column; + gap: 14px; +} + +.riga { + background: var(--color-surface); + border: 1px solid var(--color-divider); + 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: color-mix(in srgb, var(--color-text) 55%, transparent); + margin-top: 2px; +} + +.riga-azioni { + display: flex; + gap: 8px; +} + +.riga-modifica { + cursor: pointer; + padding: 9px 14px; + border-radius: 8px; + border: 1px solid var(--color-divider); + font-weight: 600; + font-size: 14px; +} + +.riga-elimina { + cursor: pointer; + padding: 9px 14px; + border-radius: 8px; + border: 1px solid var(--color-accent-800); + color: var(--color-accent-800); + 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: color-mix(in srgb, var(--color-text) 55%, transparent); + text-transform: uppercase; +} + +.badge-attesa { + cursor: default; + font-size: 14px; + margin-left: 4px; +} + +.empty-state { + padding: 24px 0; + color: color-mix(in srgb, var(--color-text) 55%, transparent); + font-size: 14px; +} + +.empty-title { + font-weight: 700; + color: var(--color-text); +} + +.btn-primary { + cursor: pointer; + display: inline-block; + padding: 11px 18px; + border-radius: 10px; + background: var(--color-accent); + color: #fff; + font-weight: 700; + border: none; + font-family: inherit; + font-size: 15px; +} + +.btn-primary:hover { + background: var(--color-accent-600); +} + +.btn-secondary { + cursor: pointer; + display: inline-block; + padding: 9px 14px; + border-radius: 8px; + border: 1px solid var(--color-divider); + font-weight: 600; + font-size: 14px; + background: transparent; + color: var(--color-text); + font-family: inherit; +} diff --git a/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.html b/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.html new file mode 100644 index 0000000..c65bc71 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.html @@ -0,0 +1,319 @@ +
+ + +
+
Materiali
+
Categorie
+
Liste in attesa
+
Tipi evento
+
+ + @if (tab() === 'materiali') { +

Materiali proposti dalle organizzazioni, in attesa di approvazione.

+ + @if (loading()) { +
Caricamento...
+ } @else if (loadError()) { +
{{ loadError() }}
+ } @else if (proposte().length > 0) { +
+
Proposte in attesa di approvazione
+ @for (proposta of proposte(); track proposta.id) { +
+
+
{{ proposta.nome }}
+
+ {{ proposta.categoria }} · {{ proposta.unitaMisura }} · proposto il {{ proposta.creatoIl | slice: 0 : 10 }} +
+ @if (decisioneErroreId() === proposta.id) { +
Impossibile registrare la decisione. Riprova più tardi.
+ } +
+
+
Approva
+
Rifiuta
+
+
+ } +
+ } @else { +
+
Nessuna proposta in attesa di approvazione
+
+ } + + @if (materialiLoading()) { +
Caricamento...
+ } @else if (materialiLoadError()) { +
{{ materialiLoadError() }}
+ } @else { +
+
+ + + +
+ @if (materialeSalvataggioErrore()) { +
{{ materialeSalvataggioErrore() }}
+ } +
+
+ {{ materialeEditId() ? 'Salva modifiche' : '+ Aggiungi materiale' }} +
+ @if (materialeEditId()) { +
Annulla
+ } +
+
+ + @if (materiali().length === 0) { +
+
Nessun materiale presente
+
+ } @else { +
+ @for (materiale of materiali(); track materiale.id) { +
+
+
{{ materiale.nome }}
+
{{ materiale.categoria }} · {{ materiale.unitaMisura }}
+ @if (materialeEliminaErroreId() === materiale.id) { +
{{ materialeEliminaErroreMsg() }}
+ } +
+
+
Modifica
+
Elimina
+
+
+ } +
+ } + } + } + + @if (tab() === 'categorie') { +

Categorie di materiale: gestite direttamente dal moderatore, oppure proposte da un'organizzazione e in attesa di conferma.

+ + @if (categorieLoading()) { +
Caricamento...
+ } @else if (categorieLoadError()) { +
{{ categorieLoadError() }}
+ } @else { + @if (categoriaProposte().length > 0) { +
+
Proposte in attesa di approvazione
+ @for (categoria of categoriaProposte(); track categoria.id) { +
+
+
+ {{ categoria.nome }} + ⚠️ +
+ @if (categoriaApprovazioneErroreId() === categoria.id) { +
{{ categoriaApprovazioneErroreMsg() }}
+ } +
+
+
Approva
+
Rifiuta
+
+
+ } +
+ } + +
+
+ +
+ @if (categoriaSalvataggioErrore()) { +
{{ categoriaSalvataggioErrore() }}
+ } +
+
+ {{ categoriaEditId() ? 'Salva modifiche' : '+ Aggiungi categoria' }} +
+ @if (categoriaEditId()) { +
Annulla
+ } +
+
+ + @if (categoriaConfermate().length > 0) { +
+ @for (categoria of categoriaConfermate(); track categoria.id) { +
+
+
{{ categoria.nome }}
+ @if (categoriaEliminaErroreId() === categoria.id) { +
{{ categoriaEliminaErroreMsg() }}
+ } +
+
+
Modifica
+
Elimina
+
+
+ } +
+ } @else { +
+
Nessuna categoria presente
+
+ } + } + } + + @if (tab() === 'liste') { +

Liste pubbliche proposte dagli utenti, in attesa di approvazione.

+ + @if (listeLoading()) { +
Caricamento...
+ } @else if (listeLoadError()) { +
{{ listeLoadError() }}
+ } @else if (listeProposte().length === 0) { +
+
Nessuna lista in attesa di approvazione
+
+ } @else { +
+ @for (lista of listeProposte(); track lista.id) { +
+
+
{{ lista.nome }}
+
{{ lista.voci.length }} voci · proposta il {{ lista.creataIl | slice: 0 : 10 }}
+ @if (listaDecisioneErroreId() === lista.id) { +
Impossibile registrare la decisione. Riprova più tardi.
+ } +
+
+
Approva
+
Rifiuta
+
+
+ } +
+ } + } + + @if (tab() === 'tipiEvento') { +

Tipi evento usati per classificare le liste pubbliche: gestiti direttamente dal moderatore, oppure proposti da un'organizzazione e in attesa di conferma.

+ + @if (tipiEventoLoading()) { +
Caricamento...
+ } @else if (tipiEventoLoadError()) { +
{{ tipiEventoLoadError() }}
+ } @else { + @if (tipoEventoProposti().length > 0) { +
+
Proposte in attesa di approvazione
+ @for (tipoEvento of tipoEventoProposti(); track tipoEvento.id) { +
+
+
+ {{ tipoEvento.nome }} + ⚠️ +
+ @if (tipoEventoApprovazioneErroreId() === tipoEvento.id) { +
{{ tipoEventoApprovazioneErroreMsg() }}
+ } +
+
+
Approva
+
Rifiuta
+
+
+ } +
+ } + +
+
+ +
+ @if (tipoEventoSalvataggioErrore()) { +
{{ tipoEventoSalvataggioErrore() }}
+ } +
+
+ {{ tipoEventoEditId() ? 'Salva modifiche' : '+ Aggiungi tipo evento' }} +
+ @if (tipoEventoEditId()) { +
Annulla
+ } +
+
+ + @if (tipoEventoConfermati().length > 0) { +
+ @for (tipoEvento of tipoEventoConfermati(); track tipoEvento.id) { +
+
+
{{ tipoEvento.nome }}
+ @if (tipoEventoEliminaErroreId() === tipoEvento.id) { +
{{ tipoEventoEliminaErroreMsg() }}
+ } +
+
+
Modifica
+
Elimina
+
+
+ } +
+ } @else { +
+
Nessun tipo evento presente
+
+ } + } + } +
diff --git a/scouthub-magazzino-fe/src/app/moderazione/moderazione.routes.ts b/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.routes.ts similarity index 68% rename from scouthub-magazzino-fe/src/app/moderazione/moderazione.routes.ts rename to scouthub-magazzino-fe/src/app/tassonomie/tassonomie.routes.ts index 7fa0edc..c4fa5ed 100644 --- a/scouthub-magazzino-fe/src/app/moderazione/moderazione.routes.ts +++ b/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.routes.ts @@ -3,10 +3,10 @@ import { Routes } from '@angular/router'; import { requireAuthGuard } from '../core/require-auth.guard'; import { requireModeratoreGuard } from './require-moderatore.guard'; -export const MODERAZIONE_ROUTES: Routes = [ +export const TASSONOMIE_ROUTES: Routes = [ { path: '', - loadComponent: () => import('./moderazione').then((m) => m.Moderazione), + loadComponent: () => import('./tassonomie').then((m) => m.Tassonomie), canActivate: [requireAuthGuard, requireModeratoreGuard] } ]; diff --git a/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.spec.ts b/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.spec.ts new file mode 100644 index 0000000..6950156 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.spec.ts @@ -0,0 +1,400 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { MaterialePubblico, MaterialeProposta, MaterialiApiService } from '../catalogo/materiali-api.service'; +import { TipoEvento, TipiEventoApiService } from '../catalogo/tipi-evento-api.service'; +import { Lista } from '../liste/liste-api.service'; +import { Categoria, CategorieApiService } from './categorie-api.service'; +import { MaterialiModerazioneApiService } from './materiali-moderazione-api.service'; +import { ListeModerazioneApiService } from './liste-moderazione-api.service'; +import { Tassonomie } from './tassonomie'; + +describe('Tassonomie', () => { + let component: Tassonomie; + let fixture: ComponentFixture; + let moderazioneApi: { getProposte: ReturnType; decidiProposta: ReturnType }; + let materialiApi: { + getMateriali: ReturnType; + creaMateriale: ReturnType; + aggiornaMateriale: ReturnType; + eliminaMateriale: ReturnType; + }; + let listeModerazioneApi: { getProposte: ReturnType; decidiProposta: ReturnType }; + let categorieApi: { + getCategorie: ReturnType; + creaCategoria: ReturnType; + aggiornaCategoria: ReturnType; + eliminaCategoria: ReturnType; + approvaCategoria: ReturnType; + rifiutaCategoria: ReturnType; + }; + let tipiEventoApi: { + getTipiEvento: ReturnType; + getTipiEventoModerazione: ReturnType; + createTipoEvento: ReturnType; + proponiTipoEvento: ReturnType; + updateTipoEvento: ReturnType; + deleteTipoEvento: ReturnType; + approvaTipoEvento: ReturnType; + rifiutaTipoEvento: ReturnType; + }; + + const proposte: MaterialeProposta[] = [ + { + id: 'prop-1', + nome: 'Fune da bucato', + categoria: 'Campeggio', + unitaMisura: 'pz', + stato: 'proposto', + propostoDaOrgId: 'org-1', + creatoIl: '2026-01-01T00:00:00.000Z' + }, + { + id: 'prop-2', + nome: 'Piccone', + categoria: 'Attrezzatura', + unitaMisura: 'pz', + stato: 'proposto', + propostoDaOrgId: 'org-2', + creatoIl: '2026-01-02T00:00:00.000Z' + } + ]; + + const listeProposte: Lista[] = [ + { + id: 'lista-prop-1', + nome: 'Uscita di un giorno', + orgId: null, + stato: 'pubblico', + statoModerazione: 'proposto', + tipoEventoId: null, + parentId: null, + creataIl: '2026-01-03T00:00:00.000Z', + creataDaMe: false, + voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }], + sottoListe: [] + } + ]; + + const categorie: Categoria[] = [ + { id: 'cat-1', nome: 'Cucina', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' }, + { id: 'cat-2', nome: 'Escursionismo', stato: 'da_approvare', creatoDaOrgId: 'org-1', creatoIl: '2026-01-02T00:00:00.000Z' } + ]; + + const materiali: MaterialePubblico[] = [{ id: 'mat-esist-1', nome: 'Corda 10m', categoria: 'Campeggio', unitaMisura: 'pz' }]; + + const tipiEvento: TipoEvento[] = [ + { id: 'te-1', nome: 'Campo estivo', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' }, + { id: 'te-2', nome: 'Bivacco', stato: 'da_approvare', creatoDaOrgId: 'org-1', creatoIl: '2026-01-02T00:00:00.000Z' } + ]; + + function elenco(compiled: HTMLElement): Element | null { + return compiled.querySelector('.lista'); + } + + async function setup(): Promise { + await TestBed.configureTestingModule({ + imports: [Tassonomie], + providers: [ + provideRouter([]), + { provide: MaterialiModerazioneApiService, useValue: moderazioneApi }, + { provide: MaterialiApiService, useValue: materialiApi }, + { provide: ListeModerazioneApiService, useValue: listeModerazioneApi }, + { provide: CategorieApiService, useValue: categorieApi }, + { provide: TipiEventoApiService, useValue: tipiEventoApi } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(Tassonomie); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + beforeEach(() => { + moderazioneApi = { getProposte: vi.fn().mockReturnValue(of(proposte)), decidiProposta: vi.fn() }; + materialiApi = { + getMateriali: vi.fn().mockReturnValue(of(materiali)), + creaMateriale: vi.fn(), + aggiornaMateriale: vi.fn(), + eliminaMateriale: vi.fn() + }; + listeModerazioneApi = { getProposte: vi.fn().mockReturnValue(of(listeProposte)), decidiProposta: vi.fn() }; + categorieApi = { + getCategorie: vi.fn().mockReturnValue(of(categorie)), + creaCategoria: vi.fn(), + aggiornaCategoria: vi.fn(), + eliminaCategoria: vi.fn(), + approvaCategoria: vi.fn(), + rifiutaCategoria: vi.fn() + }; + tipiEventoApi = { + getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)), + getTipiEventoModerazione: vi.fn().mockReturnValue(of(tipiEvento)), + createTipoEvento: vi.fn(), + proponiTipoEvento: vi.fn(), + updateTipoEvento: vi.fn(), + deleteTipoEvento: vi.fn(), + approvaTipoEvento: vi.fn(), + rifiutaTipoEvento: vi.fn() + }; + }); + + it('mostra la lista delle proposte materiali in attesa nel tab di default', async () => { + await setup(); + fixture.detectChanges(); + + expect(component.loading()).toBe(false); + expect(component.proposte()).toEqual(proposte); + + const compiled = fixture.nativeElement as HTMLElement; + const righe = compiled.querySelector('.proposte-box')!.querySelectorAll('.riga'); + expect(righe.length).toBe(2); + expect(righe[0].textContent).toContain('Fune da bucato'); + }); + + it('mostra un messaggio se non ci sono proposte materiali in attesa', async () => { + moderazioneApi.getProposte.mockReturnValue(of([])); + + await setup(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Nessuna proposta in attesa'); + }); + + it('mostra un messaggio di errore se il caricamento delle proposte materiali fallisce', async () => { + moderazioneApi.getProposte.mockReturnValue(throwError(() => new Error('network error'))); + + await setup(); + + expect(component.loadError()).toBe('Impossibile caricare le proposte in attesa. Riprova più tardi.'); + }); + + it('mostra la lista delle liste in attesa di approvazione nel tab dedicato', async () => { + await setup(); + component.setTab('liste'); + fixture.detectChanges(); + + expect(component.listeLoading()).toBe(false); + expect(component.listeProposte()).toEqual(listeProposte); + + const compiled = fixture.nativeElement as HTMLElement; + const righe = elenco(compiled)!.querySelectorAll('.riga'); + expect(righe.length).toBe(1); + expect(righe[0].textContent).toContain('Uscita di un giorno'); + }); + + it('mostra un messaggio se non ci sono liste in attesa', async () => { + listeModerazioneApi.getProposte.mockReturnValue(of([])); + + await setup(); + component.setTab('liste'); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Nessuna lista in attesa'); + }); + + it('mostra un messaggio di errore se il caricamento delle liste fallisce', async () => { + listeModerazioneApi.getProposte.mockReturnValue(throwError(() => new Error('network error'))); + + await setup(); + + expect(component.listeLoadError()).toBe('Impossibile caricare le liste in attesa. Riprova più tardi.'); + }); + + describe('approvazione e rifiuto materiali', () => { + beforeEach(async () => { + await setup(); + }); + + it('approva una proposta e la rimuove dalla lista', async () => { + const propostaApprovata: MaterialeProposta = { ...proposte[0], stato: 'approvato' }; + moderazioneApi.decidiProposta.mockReturnValue(of(propostaApprovata)); + + await component.decidi(proposte[0], 'approvato'); + + expect(moderazioneApi.decidiProposta).toHaveBeenCalledWith('prop-1', 'approvato'); + expect(component.proposte().map((p) => p.id)).toEqual(['prop-2']); + }); + + it('rifiuta una proposta e la rimuove dalla lista', async () => { + const propostaRifiutata: MaterialeProposta = { ...proposte[1], stato: 'rifiutato' }; + moderazioneApi.decidiProposta.mockReturnValue(of(propostaRifiutata)); + + await component.decidi(proposte[1], 'rifiutato'); + + expect(moderazioneApi.decidiProposta).toHaveBeenCalledWith('prop-2', 'rifiutato'); + expect(component.proposte().map((p) => p.id)).toEqual(['prop-1']); + }); + + it('mostra un messaggio di errore sulla proposta interessata se la decisione fallisce', async () => { + moderazioneApi.decidiProposta.mockReturnValue(throwError(() => new Error('server error'))); + + await component.decidi(proposte[0], 'approvato'); + + expect(component.decisioneErroreId()).toBe('prop-1'); + expect(component.proposte().map((p) => p.id)).toEqual(['prop-1', 'prop-2']); + + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Impossibile registrare la decisione'); + }); + }); + + describe('approvazione e rifiuto liste', () => { + beforeEach(async () => { + await setup(); + component.setTab('liste'); + fixture.detectChanges(); + }); + + it('approva una lista e la rimuove dalla lista di attesa', async () => { + const listaApprovata: Lista = { ...listeProposte[0], statoModerazione: 'approvato' }; + listeModerazioneApi.decidiProposta.mockReturnValue(of(listaApprovata)); + + await component.decidiLista(listeProposte[0], 'approvato'); + + expect(listeModerazioneApi.decidiProposta).toHaveBeenCalledWith('lista-prop-1', 'approvato'); + expect(component.listeProposte()).toEqual([]); + }); + + it('mostra un messaggio di errore sulla lista interessata se la decisione fallisce', async () => { + listeModerazioneApi.decidiProposta.mockReturnValue(throwError(() => new Error('server error'))); + + await component.decidiLista(listeProposte[0], 'approvato'); + + expect(component.listaDecisioneErroreId()).toBe('lista-prop-1'); + + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Impossibile registrare la decisione'); + }); + }); + + describe('categorie', () => { + beforeEach(async () => { + await setup(); + component.setTab('categorie'); + fixture.detectChanges(); + }); + + it('separa le categorie confermate da quelle proposte', () => { + expect(component.categoriaConfermate().map((c) => c.id)).toEqual(['cat-1']); + expect(component.categoriaProposte().map((c) => c.id)).toEqual(['cat-2']); + }); + + it('crea una nuova categoria confermata', async () => { + const creata: Categoria = { id: 'cat-3', nome: 'Attrezzi', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-04T00:00:00.000Z' }; + categorieApi.creaCategoria.mockReturnValue(of(creata)); + + component.categoriaNome.set('Attrezzi'); + await component.salvaCategoria(); + + expect(categorieApi.creaCategoria).toHaveBeenCalledWith('Attrezzi'); + expect(component.categoriaList().some((c) => c.id === 'cat-3')).toBe(true); + expect(component.categoriaNome()).toBe(''); + }); + + it('approva una categoria proposta e la sposta tra le confermate', async () => { + const approvata: Categoria = { ...categorie[1], stato: 'confermata' }; + categorieApi.approvaCategoria.mockReturnValue(of(approvata)); + + await component.approvaCategoria(categorie[1]); + + expect(categorieApi.approvaCategoria).toHaveBeenCalledWith('cat-2'); + expect(component.categoriaProposte()).toEqual([]); + expect(component.categoriaConfermate().map((c) => c.id)).toContain('cat-2'); + }); + + it('rifiuta una categoria proposta e la rimuove', async () => { + categorieApi.rifiutaCategoria.mockReturnValue(of(undefined)); + + await component.rifiutaCategoria(categorie[1]); + + expect(categorieApi.rifiutaCategoria).toHaveBeenCalledWith('cat-2'); + expect(component.categoriaList().some((c) => c.id === 'cat-2')).toBe(false); + }); + + it('elimina una categoria confermata', async () => { + categorieApi.eliminaCategoria.mockReturnValue(of(undefined)); + + await component.eliminaCategoria(categorie[0]); + + expect(categorieApi.eliminaCategoria).toHaveBeenCalledWith('cat-1'); + expect(component.categoriaList().some((c) => c.id === 'cat-1')).toBe(false); + }); + }); + + describe('tipi evento', () => { + beforeEach(async () => { + await setup(); + component.setTab('tipiEvento'); + fixture.detectChanges(); + }); + + it('separa i tipi evento confermati da quelli proposti', () => { + expect(component.tipoEventoConfermati().map((t) => t.id)).toEqual(['te-1']); + expect(component.tipoEventoProposti().map((t) => t.id)).toEqual(['te-2']); + }); + + it('crea un nuovo tipo evento confermato', async () => { + const creato: TipoEvento = { + id: 'te-3', + nome: 'Bivacco', + stato: 'confermata', + creatoDaOrgId: null, + creatoIl: '2026-01-03T00:00:00.000Z' + }; + tipiEventoApi.createTipoEvento.mockReturnValue(of(creato)); + + component.tipoEventoNome.set('Bivacco'); + await component.salvaTipoEvento(); + + expect(tipiEventoApi.createTipoEvento).toHaveBeenCalledWith('Bivacco'); + expect(component.tipoEventoList().some((t) => t.id === 'te-3')).toBe(true); + }); + + it('modifica un tipo evento esistente', async () => { + const aggiornato: TipoEvento = { ...tipiEvento[0], nome: 'Campo estivo modificato' }; + tipiEventoApi.updateTipoEvento.mockReturnValue(of(aggiornato)); + + component.modificaTipoEvento(tipiEvento[0]); + component.tipoEventoNome.set('Campo estivo modificato'); + await component.salvaTipoEvento(); + + expect(tipiEventoApi.updateTipoEvento).toHaveBeenCalledWith('te-1', 'Campo estivo modificato'); + }); + + it('elimina un tipo evento confermato', async () => { + tipiEventoApi.deleteTipoEvento.mockReturnValue(of(undefined)); + + await component.eliminaTipoEvento(tipiEvento[0]); + + expect(tipiEventoApi.deleteTipoEvento).toHaveBeenCalledWith('te-1'); + expect(component.tipoEventoList().some((t) => t.id === 'te-1')).toBe(false); + }); + + it('approva un tipo evento proposto e lo sposta tra i confermati', async () => { + const approvato: TipoEvento = { ...tipiEvento[1], stato: 'confermata' }; + tipiEventoApi.approvaTipoEvento.mockReturnValue(of(approvato)); + + await component.approvaTipoEvento(tipiEvento[1]); + + expect(tipiEventoApi.approvaTipoEvento).toHaveBeenCalledWith('te-2'); + expect(component.tipoEventoProposti()).toEqual([]); + expect(component.tipoEventoConfermati().map((t) => t.id)).toContain('te-2'); + }); + + it('rifiuta un tipo evento proposto e lo rimuove', async () => { + tipiEventoApi.rifiutaTipoEvento.mockReturnValue(of(undefined)); + + await component.rifiutaTipoEvento(tipiEvento[1]); + + expect(tipiEventoApi.rifiutaTipoEvento).toHaveBeenCalledWith('te-2'); + expect(component.tipoEventoList().some((t) => t.id === 'te-2')).toBe(false); + }); + }); +}); diff --git a/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.ts b/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.ts new file mode 100644 index 0000000..969e952 --- /dev/null +++ b/scouthub-magazzino-fe/src/app/tassonomie/tassonomie.ts @@ -0,0 +1,443 @@ +import { SlicePipe } from '@angular/common'; +import { Component, OnInit, inject, signal, computed } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; + +import { MaterialePubblico, MaterialeProposta, MaterialiApiService } from '../catalogo/materiali-api.service'; +import { TipoEvento, TipiEventoApiService } from '../catalogo/tipi-evento-api.service'; +import { Lista } from '../liste/liste-api.service'; +import { Categoria, CategorieApiService } from './categorie-api.service'; +import { DecisioneProposta, MaterialiModerazioneApiService } from './materiali-moderazione-api.service'; +import { ListeModerazioneApiService } from './liste-moderazione-api.service'; + +type Tab = 'materiali' | 'categorie' | 'liste' | 'tipiEvento'; + +const TAB_VALIDI: readonly Tab[] = ['materiali', 'categorie', 'liste', 'tipiEvento']; + +@Component({ + selector: 'app-tassonomie', + imports: [SlicePipe], + templateUrl: './tassonomie.html', + styleUrl: './tassonomie.css' +}) +export class Tassonomie implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly moderazioneApi = inject(MaterialiModerazioneApiService); + private readonly materialiApi = inject(MaterialiApiService); + private readonly listeModerazioneApi = inject(ListeModerazioneApiService); + private readonly categorieApi = inject(CategorieApiService); + private readonly tipiEventoApi = inject(TipiEventoApiService); + + readonly tab = signal('materiali'); + + // — Materiali proposti — + readonly loading = signal(true); + readonly loadError = signal(null); + readonly proposte = signal([]); + readonly decisioneInCorsoId = signal(null); + readonly decisioneErroreId = signal(null); + + // Materiali già approvati, mostrati sotto le proposte in attesa (stesso pattern + // della tab "Categorie": la moderazione deve vedere anche il catalogo esistente, + // non solo le richieste da decidere). + readonly materialiLoading = signal(true); + readonly materialiLoadError = signal(null); + readonly materiali = signal([]); + + readonly materialeEditId = signal(null); + readonly materialeNome = signal(''); + readonly materialeCategoria = signal(''); + readonly materialeUnitaMisura = signal(''); + readonly materialeSalvataggioErrore = signal(null); + readonly materialeEliminaErroreId = signal(null); + readonly materialeEliminaErroreMsg = signal(null); + + // — Liste in attesa — + // Le liste dei moderatori (il vecchio "catalogo liste modello") vengono scritte + // raramente: gli utenti propongono liste pubbliche che passano da qui prima di + // diventare campioni riusabili come base per altre liste. + readonly listeLoading = signal(true); + readonly listeLoadError = signal(null); + readonly listeProposte = signal([]); + readonly listaDecisioneInCorsoId = signal(null); + readonly listaDecisioneErroreId = signal(null); + + // — Categorie — + readonly categorieLoading = signal(true); + readonly categorieLoadError = signal(null); + readonly categoriaList = signal([]); + readonly categoriaConfermate = computed(() => this.categoriaList().filter((c) => c.stato === 'confermata')); + readonly categoriaProposte = computed(() => this.categoriaList().filter((c) => c.stato === 'da_approvare')); + + readonly categoriaEditId = signal(null); + readonly categoriaNome = signal(''); + readonly categoriaSalvataggioErrore = signal(null); + + readonly categoriaEliminaErroreId = signal(null); + readonly categoriaEliminaErroreMsg = signal(null); + readonly categoriaApprovazioneErroreId = signal(null); + readonly categoriaApprovazioneErroreMsg = signal(null); + + // — Tipi evento — + readonly tipiEventoLoading = signal(true); + readonly tipiEventoLoadError = signal(null); + readonly tipoEventoList = signal([]); + readonly tipoEventoConfermati = computed(() => this.tipoEventoList().filter((t) => t.stato === 'confermata')); + readonly tipoEventoProposti = computed(() => this.tipoEventoList().filter((t) => t.stato === 'da_approvare')); + + readonly tipoEventoApprovazioneErroreId = signal(null); + readonly tipoEventoApprovazioneErroreMsg = signal(null); + + readonly tipoEventoEditId = signal(null); + readonly tipoEventoNome = signal(''); + readonly tipoEventoSalvataggioErrore = signal(null); + readonly tipoEventoEliminaErroreId = signal(null); + readonly tipoEventoEliminaErroreMsg = signal(null); + + async ngOnInit(): Promise { + // Arrivo diretto da un link esterno (es. la campanellina di notifica), che + // punta a un tab specifico tramite ?tab=... invece che sempre a 'materiali'. + const tabIniziale = this.route.snapshot.queryParamMap.get('tab'); + if (tabIniziale && (TAB_VALIDI as string[]).includes(tabIniziale)) { + this.tab.set(tabIniziale as Tab); + } + + await Promise.all([ + this.caricaProposte(), + this.caricaMateriali(), + this.caricaListeProposte(), + this.caricaCategorie(), + this.caricaTipiEvento() + ]); + } + + setTab(tab: Tab): void { + this.tab.set(tab); + } + + // — Materiali — + + async decidi(proposta: MaterialeProposta, decisione: DecisioneProposta): Promise { + if (this.decisioneInCorsoId()) { + return; + } + + this.decisioneErroreId.set(null); + this.decisioneInCorsoId.set(proposta.id); + + try { + await firstValueFrom(this.moderazioneApi.decidiProposta(proposta.id, decisione)); + this.proposte.update((proposte) => proposte.filter((p) => p.id !== proposta.id)); + } catch { + this.decisioneErroreId.set(proposta.id); + } finally { + this.decisioneInCorsoId.set(null); + } + } + + private async caricaProposte(): Promise { + this.loading.set(true); + this.loadError.set(null); + + try { + const proposte = await firstValueFrom(this.moderazioneApi.getProposte()); + this.proposte.set(proposte); + } catch { + this.loadError.set('Impossibile caricare le proposte in attesa. Riprova più tardi.'); + } finally { + this.loading.set(false); + } + } + + private async caricaMateriali(): Promise { + this.materialiLoading.set(true); + this.materialiLoadError.set(null); + + try { + const materiali = await firstValueFrom(this.materialiApi.getMateriali()); + this.materiali.set(materiali); + } catch { + this.materialiLoadError.set('Impossibile caricare i materiali. Riprova più tardi.'); + } finally { + this.materialiLoading.set(false); + } + } + + modificaMateriale(materiale: MaterialePubblico): void { + this.materialeEditId.set(materiale.id); + this.materialeNome.set(materiale.nome); + this.materialeCategoria.set(materiale.categoria); + this.materialeUnitaMisura.set(materiale.unitaMisura); + this.materialeSalvataggioErrore.set(null); + } + + annullaMateriale(): void { + this.materialeEditId.set(null); + this.materialeNome.set(''); + this.materialeCategoria.set(''); + this.materialeUnitaMisura.set(''); + this.materialeSalvataggioErrore.set(null); + } + + async salvaMateriale(): Promise { + const nome = this.materialeNome().trim(); + const categoria = this.materialeCategoria().trim(); + const unitaMisura = this.materialeUnitaMisura().trim(); + if (!nome || !categoria || !unitaMisura) { + this.materialeSalvataggioErrore.set('Nome, categoria e unità di misura sono obbligatori.'); + return; + } + + this.materialeSalvataggioErrore.set(null); + try { + const editId = this.materialeEditId(); + const input = { nome, categoria, unitaMisura }; + const salvato = editId + ? await firstValueFrom(this.materialiApi.aggiornaMateriale(editId, input)) + : await firstValueFrom(this.materialiApi.creaMateriale(input)); + + this.materiali.update((lista) => { + const senzaVecchio = lista.filter((m) => m.id !== salvato.id); + return [...senzaVecchio, salvato].sort((a, b) => a.nome.localeCompare(b.nome)); + }); + this.annullaMateriale(); + } catch { + this.materialeSalvataggioErrore.set('Impossibile salvare il materiale.'); + } + } + + async eliminaMateriale(materiale: MaterialePubblico): Promise { + this.materialeEliminaErroreId.set(null); + this.materialeEliminaErroreMsg.set(null); + try { + await firstValueFrom(this.materialiApi.eliminaMateriale(materiale.id)); + this.materiali.update((lista) => lista.filter((m) => m.id !== materiale.id)); + } catch (err) { + this.materialeEliminaErroreId.set(materiale.id); + this.materialeEliminaErroreMsg.set(this.estraiMessaggioErrore(err)); + } + } + + // — Liste — + + async decidiLista(lista: Lista, decisione: DecisioneProposta): Promise { + if (this.listaDecisioneInCorsoId()) { + return; + } + + this.listaDecisioneErroreId.set(null); + this.listaDecisioneInCorsoId.set(lista.id); + + try { + await firstValueFrom(this.listeModerazioneApi.decidiProposta(lista.id, decisione)); + this.listeProposte.update((liste) => liste.filter((l) => l.id !== lista.id)); + } catch { + this.listaDecisioneErroreId.set(lista.id); + } finally { + this.listaDecisioneInCorsoId.set(null); + } + } + + private async caricaListeProposte(): Promise { + this.listeLoading.set(true); + this.listeLoadError.set(null); + + try { + const liste = await firstValueFrom(this.listeModerazioneApi.getProposte()); + this.listeProposte.set(liste); + } catch { + this.listeLoadError.set('Impossibile caricare le liste in attesa. Riprova più tardi.'); + } finally { + this.listeLoading.set(false); + } + } + + // — Categorie — + + private async caricaCategorie(): Promise { + this.categorieLoading.set(true); + this.categorieLoadError.set(null); + + try { + const categorie = await firstValueFrom(this.categorieApi.getCategorie()); + this.categoriaList.set(categorie); + } catch { + this.categorieLoadError.set('Impossibile caricare le categorie. Riprova più tardi.'); + } finally { + this.categorieLoading.set(false); + } + } + + modificaCategoria(categoria: Categoria): void { + this.categoriaEditId.set(categoria.id); + this.categoriaNome.set(categoria.nome); + this.categoriaSalvataggioErrore.set(null); + } + + annullaCategoria(): void { + this.categoriaEditId.set(null); + this.categoriaNome.set(''); + this.categoriaSalvataggioErrore.set(null); + } + + async salvaCategoria(): Promise { + const nome = this.categoriaNome().trim(); + if (!nome) { + return; + } + + this.categoriaSalvataggioErrore.set(null); + try { + const editId = this.categoriaEditId(); + const salvata = editId + ? await firstValueFrom(this.categorieApi.aggiornaCategoria(editId, nome)) + : await firstValueFrom(this.categorieApi.creaCategoria(nome)); + + 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 { + this.categoriaEliminaErroreId.set(null); + this.categoriaEliminaErroreMsg.set(null); + try { + await firstValueFrom(this.categorieApi.eliminaCategoria(categoria.id)); + this.categoriaList.update((lista) => lista.filter((c) => c.id !== categoria.id)); + } catch (err) { + this.categoriaEliminaErroreId.set(categoria.id); + this.categoriaEliminaErroreMsg.set(this.estraiMessaggioErrore(err)); + } + } + + async approvaCategoria(categoria: Categoria): Promise { + this.categoriaApprovazioneErroreId.set(null); + this.categoriaApprovazioneErroreMsg.set(null); + try { + const approvata = await firstValueFrom(this.categorieApi.approvaCategoria(categoria.id)); + this.categoriaList.update((lista) => lista.map((c) => (c.id === approvata.id ? approvata : c))); + } catch (err) { + this.categoriaApprovazioneErroreId.set(categoria.id); + this.categoriaApprovazioneErroreMsg.set(this.estraiMessaggioErrore(err)); + } + } + + async rifiutaCategoria(categoria: Categoria): Promise { + this.categoriaApprovazioneErroreId.set(null); + this.categoriaApprovazioneErroreMsg.set(null); + try { + await firstValueFrom(this.categorieApi.rifiutaCategoria(categoria.id)); + this.categoriaList.update((lista) => lista.filter((c) => c.id !== categoria.id)); + } catch (err) { + this.categoriaApprovazioneErroreId.set(categoria.id); + this.categoriaApprovazioneErroreMsg.set(this.estraiMessaggioErrore(err)); + } + } + + // — Tipi evento — + + private async caricaTipiEvento(): Promise { + this.tipiEventoLoading.set(true); + this.tipiEventoLoadError.set(null); + + try { + const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEventoModerazione()); + this.tipoEventoList.set(tipiEvento); + } catch { + this.tipiEventoLoadError.set('Impossibile caricare i tipi evento. Riprova più tardi.'); + } finally { + this.tipiEventoLoading.set(false); + } + } + + modificaTipoEvento(tipoEvento: TipoEvento): void { + this.tipoEventoEditId.set(tipoEvento.id); + this.tipoEventoNome.set(tipoEvento.nome); + this.tipoEventoSalvataggioErrore.set(null); + } + + annullaTipoEvento(): void { + this.tipoEventoEditId.set(null); + this.tipoEventoNome.set(''); + this.tipoEventoSalvataggioErrore.set(null); + } + + async salvaTipoEvento(): Promise { + const nome = this.tipoEventoNome().trim(); + if (!nome) { + return; + } + + this.tipoEventoSalvataggioErrore.set(null); + try { + const editId = this.tipoEventoEditId(); + const salvato = editId + ? await firstValueFrom(this.tipiEventoApi.updateTipoEvento(editId, nome)) + : await firstValueFrom(this.tipiEventoApi.createTipoEvento(nome)); + + this.tipoEventoList.update((lista) => { + const senzaVecchio = lista.filter((t) => t.id !== salvato.id); + return [...senzaVecchio, salvato].sort((a, b) => a.nome.localeCompare(b.nome)); + }); + this.annullaTipoEvento(); + } catch { + this.tipoEventoSalvataggioErrore.set('Impossibile salvare il tipo evento.'); + } + } + + async eliminaTipoEvento(tipoEvento: TipoEvento): Promise { + this.tipoEventoEliminaErroreId.set(null); + this.tipoEventoEliminaErroreMsg.set(null); + try { + await firstValueFrom(this.tipiEventoApi.deleteTipoEvento(tipoEvento.id)); + this.tipoEventoList.update((lista) => lista.filter((t) => t.id !== tipoEvento.id)); + } catch (err) { + this.tipoEventoEliminaErroreId.set(tipoEvento.id); + this.tipoEventoEliminaErroreMsg.set(this.estraiMessaggioErrore(err)); + } + } + + async approvaTipoEvento(tipoEvento: TipoEvento): Promise { + this.tipoEventoApprovazioneErroreId.set(null); + this.tipoEventoApprovazioneErroreMsg.set(null); + try { + const approvato = await firstValueFrom(this.tipiEventoApi.approvaTipoEvento(tipoEvento.id)); + this.tipoEventoList.update((lista) => lista.map((t) => (t.id === approvato.id ? approvato : t))); + } catch (err) { + this.tipoEventoApprovazioneErroreId.set(tipoEvento.id); + this.tipoEventoApprovazioneErroreMsg.set(this.estraiMessaggioErrore(err)); + } + } + + async rifiutaTipoEvento(tipoEvento: TipoEvento): Promise { + this.tipoEventoApprovazioneErroreId.set(null); + this.tipoEventoApprovazioneErroreMsg.set(null); + try { + await firstValueFrom(this.tipiEventoApi.rifiutaTipoEvento(tipoEvento.id)); + this.tipoEventoList.update((lista) => lista.filter((t) => t.id !== tipoEvento.id)); + } catch (err) { + this.tipoEventoApprovazioneErroreId.set(tipoEvento.id); + this.tipoEventoApprovazioneErroreMsg.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 completare l'operazione."; + } +} diff --git a/scouthub-magazzino-fe/src/index.html b/scouthub-magazzino-fe/src/index.html index a2724b7..ceca755 100644 --- a/scouthub-magazzino-fe/src/index.html +++ b/scouthub-magazzino-fe/src/index.html @@ -6,13 +6,6 @@ - - - - diff --git a/scouthub-magazzino-fe/src/material-theme.scss b/scouthub-magazzino-fe/src/material-theme.scss deleted file mode 100644 index 2ccd444..0000000 --- a/scouthub-magazzino-fe/src/material-theme.scss +++ /dev/null @@ -1,38 +0,0 @@ -// Include theming for Angular Material with `mat.theme()`. -// This Sass mixin will define CSS variables that are used for styling Angular Material -// components according to the Material 3 design spec. -// Learn more about theming and how to use it for your application's -// custom components at https://material.angular.dev/guide/theming -@use '@angular/material' as mat; - -html { - height: 100%; - @include mat.theme( - ( - color: ( - primary: mat.$azure-palette, - tertiary: mat.$blue-palette, - ), - typography: Roboto, - density: 0, - ) - ); -} - -body { - // Default the application to a light color theme. This can be changed to - // `dark` to enable the dark color theme, or to `light dark` to defer to the - // user's system settings. - color-scheme: light; - - // Set a default background, font and text colors for the application using - // Angular Material's system-level CSS variables. Learn more about these - // variables at https://material.angular.dev/guide/system-variables - background-color: var(--mat-sys-surface); - color: var(--mat-sys-on-surface); - font: var(--mat-sys-body-medium); - - // Reset the user agent margin. - margin: 0; - height: 100%; -} diff --git a/scouthub-magazzino-fe/src/styles.css b/scouthub-magazzino-fe/src/styles.css index 2907969..54a53c0 100644 --- a/scouthub-magazzino-fe/src/styles.css +++ b/scouthub-magazzino-fe/src/styles.css @@ -1,48 +1,53 @@ -/* Organic — design-system tokens and component classes (importato da Claude Design). */ +@import url('https://fonts.cdnfonts.com/css/opendyslexic'); + +/* Design system allineato a scouthub-attivita-fe: stessa palette oklch, stesso font, + stessi raggi/ombre "piatti" — solo i nomi dei token restano quelli storici di questo + progetto (--color-accent, --om-*, ecc.) per non dover riscrivere ogni componente. */ :root { - --color-bg: #f5ead8; - --color-surface: #ebddc5; - --color-text: #201e1d; - --color-accent: #c67139; - --color-accent-2: #7a8a5e; - --color-divider: color-mix(in srgb, #201e1d 16%, transparent); + --color-bg: oklch(96% 0.018 75); + --color-surface: oklch(99% 0.008 75); + --color-header: oklch(94% 0.02 75); + --color-text: oklch(24% 0.02 55); + --color-accent: oklch(63% 0.19 40); + --color-accent-2: oklch(63% 0.09 135); + --color-divider: oklch(87% 0.02 70); - /* Rampe tonali — generate in OKLCH su una scala di luminosità condivisa, - così lo stesso step di ogni ruolo ha lo stesso peso visivo. */ - --color-neutral-100: #f9f4ed; - --color-neutral-200: #eee7db; - --color-neutral-300: #dcd3c4; - --color-neutral-400: #c0b6a5; - --color-neutral-500: #a19786; - --color-neutral-600: #82796a; - --color-neutral-700: #645c50; - --color-neutral-800: #474238; - --color-neutral-900: #2e2b25; + /* Rampe tonali in oklch, stesso hue/chroma della coppia bg/accent qui sopra, così ogni + step ha lo stesso peso visivo cromatico del design system di scouthub-attivita-fe. */ + --color-neutral-100: oklch(97% 0.008 55); + --color-neutral-200: oklch(93% 0.012 55); + --color-neutral-300: oklch(87% 0.016 55); + --color-neutral-400: oklch(78% 0.02 55); + --color-neutral-500: oklch(68% 0.02 55); + --color-neutral-600: oklch(56% 0.02 55); + --color-neutral-700: oklch(45% 0.02 55); + --color-neutral-800: oklch(33% 0.02 55); + --color-neutral-900: oklch(22% 0.02 55); - --color-accent-100: #fff2eb; - --color-accent-200: #ffe1d0; - --color-accent-300: #ffc6a5; - --color-accent-400: #f6a06b; - --color-accent-500: #d67f48; - --color-accent-600: #b2622d; - --color-accent-700: #8c491a; - --color-accent-800: #643312; - --color-accent-900: #402310; + --color-accent-100: oklch(95% 0.03 40); + --color-accent-200: oklch(90% 0.06 40); + --color-accent-300: oklch(83% 0.1 40); + --color-accent-400: oklch(74% 0.14 40); + --color-accent-500: oklch(63% 0.19 40); + --color-accent-600: oklch(56% 0.19 40); + --color-accent-700: oklch(48% 0.17 40); + --color-accent-800: oklch(40% 0.15 40); + --color-accent-900: oklch(30% 0.12 40); - --color-accent-2-100: #f0fae1; - --color-accent-2-200: #e1eecc; - --color-accent-2-300: #ccdbb2; - --color-accent-2-400: #aebf92; - --color-accent-2-500: #8fa073; - --color-accent-2-600: #728157; - --color-accent-2-700: #56633f; - --color-accent-2-800: #3d472b; - --color-accent-2-900: #272e1b; + --color-accent-2-100: oklch(95% 0.02 135); + --color-accent-2-200: oklch(90% 0.035 135); + --color-accent-2-300: oklch(83% 0.05 135); + --color-accent-2-400: oklch(74% 0.07 135); + --color-accent-2-500: oklch(63% 0.09 135); + --color-accent-2-600: oklch(56% 0.09 135); + --color-accent-2-700: oklch(48% 0.08 135); + --color-accent-2-800: oklch(40% 0.07 135); + --color-accent-2-900: oklch(30% 0.06 135); - --font-heading: "Caprasimo", system-ui, sans-serif; - --font-heading-weight: 400; - --font-body: "Figtree", system-ui, sans-serif; + --font-heading: 'Open Dyslexic', 'Segoe UI', sans-serif; + --font-heading-weight: 700; + --font-body: 'Open Dyslexic', 'Segoe UI', sans-serif; --space-1: 4.4px; --space-2: 8.8px; @@ -52,12 +57,12 @@ --space-8: 35.2px; --radius-sm: 8px; - --radius-md: 16px; - --radius-lg: 28px; + --radius-md: 10px; + --radius-lg: 14px; - --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent); - --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent); - --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); + --shadow-sm: 0 1px 2px color-mix(in srgb, var(--color-neutral-900) 14%, transparent); + --shadow-md: 0 3px 10px color-mix(in srgb, var(--color-neutral-900) 16%, transparent); + --shadow-lg: 0 12px 32px color-mix(in srgb, var(--color-neutral-900) 22%, transparent); } html, body { @@ -123,7 +128,7 @@ figcaption { } .btn svg { display: block; } .btn:disabled { opacity: 0.45; cursor: not-allowed; } -.btn-primary { background: var(--color-accent); color: var(--color-bg); } +.btn-primary { background: var(--color-accent); color: #fff; } .btn-primary:hover { background: var(--color-accent-600); } .btn-primary:active { background: var(--color-accent-700); } .btn-secondary { border-color: var(--color-divider); } @@ -172,14 +177,15 @@ textarea.input { min-height: 90px; resize: vertical; } padding: 7px 12px; font-size: 13px; cursor: pointer; } .seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); } -.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); } +.seg-opt:has(input:checked) { background: var(--color-accent); color: #fff; } .seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } .seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; } /* — cards — */ .card { display: flex; flex-direction: column; gap: var(--space-2); - padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface); + padding: var(--space-3); border-radius: var(--radius-lg); background: var(--color-surface); + border: 1px solid var(--color-divider); } .card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); } .card-title { @@ -199,7 +205,7 @@ textarea.input { min-height: 90px; resize: vertical; } .tag { display: inline-flex; align-items: center; font-size: 11px; letter-spacing: 0.02em; padding: 3px 10px; - border-radius: calc(var(--radius-md) * 0.75); + border-radius: 999px; } .tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); } .tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); } @@ -250,12 +256,7 @@ textarea.input { min-height: 90px; resize: vertical; } .dialog-body { font-size: 14px; opacity: 0.85; } .dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); } -/* — rounded frame: tutto si ammorbidisce, i controlli piccoli diventano pillole — */ -.card, .dialog { border-radius: calc(var(--radius-lg) * 1.15); } -.btn, .tag, .seg, .input { border-radius: 999px; } -.input { padding-inline: 14px; } - -/* — layout di pagina condiviso tra le sezioni (da Claude Design) — */ +/* — layout di pagina condiviso tra le sezioni — */ .om-page { max-width: 980px; margin: 0 auto; padding: var(--space-6) var(--space-4) var(--space-8); } .om-section-title { margin: 0 0 var(--space-1); } .om-section-sub { margin: 0 0 var(--space-5); opacity: 0.7; font-size: 14px; } @@ -266,4 +267,4 @@ textarea.input { min-height: 90px; resize: vertical; } .om-empty { opacity: 0.6; font-size: 14px; padding: var(--space-4) 0; } /* — variante attiva per i .seg-opt pilotati via classe (filtri a click, non radio) — */ -.seg-opt--attiva { background: var(--color-accent); color: var(--color-bg); } +.seg-opt--attiva { background: var(--color-accent); color: #fff; }