Add scouthub-attivita-fe
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.angular
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/mcp.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
/.vscode/
|
||||||
|
/.claude/
|
||||||
|
/bash.exe.stackdump
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist/scouthub-attivita-fe/browser /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# ScouthubAttivitaFe
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.0.7.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"cli": {
|
||||||
|
"packageManager": "npm"
|
||||||
|
},
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"scouthub-attivita-fe": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kB",
|
||||||
|
"maximumError": "8kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.development.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "scouthub-attivita-fe:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "scouthub-attivita-fe:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8734
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "scouthub-attivita-fe",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "npm@11.12.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^22.0.0",
|
||||||
|
"@angular/compiler": "^22.0.0",
|
||||||
|
"@angular/core": "^22.0.0",
|
||||||
|
"@angular/forms": "^22.0.0",
|
||||||
|
"@angular/platform-browser": "^22.0.0",
|
||||||
|
"@angular/router": "^22.0.0",
|
||||||
|
"keycloak-angular": "^22.0.0",
|
||||||
|
"keycloak-js": "^26.2.4",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^22.0.7",
|
||||||
|
"@angular/cli": "^22.0.7",
|
||||||
|
"@angular/compiler-cli": "^22.0.0",
|
||||||
|
"jsdom": "^28.0.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
parent.postMessage(location.href, location.origin);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
createInterceptorCondition,
|
||||||
|
IncludeBearerTokenCondition,
|
||||||
|
includeBearerTokenInterceptor,
|
||||||
|
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG
|
||||||
|
} from 'keycloak-angular';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { provideKeycloakAngular } from './core/auth/keycloak.provider';
|
||||||
|
import { environment } from '../environments/environment';
|
||||||
|
|
||||||
|
const escapedApiUrl = environment.apiUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
const apiBearerCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
|
||||||
|
urlPattern: new RegExp(`^${escapedApiUrl}(/.*)?$`, 'i'),
|
||||||
|
bearerPrefix: 'Bearer'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideKeycloakAngular(),
|
||||||
|
{
|
||||||
|
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
|
||||||
|
useValue: [apiBearerCondition]
|
||||||
|
},
|
||||||
|
provideHttpClient(withInterceptors([includeBearerTokenInterceptor]))
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="app-shell">
|
||||||
|
<app-header />
|
||||||
|
<router-outlet />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { authGuard } from './core/auth/auth.guard';
|
||||||
|
import { ActivityForm } from './pages/activity-form/activity-form';
|
||||||
|
import { Detail } from './pages/detail/detail';
|
||||||
|
import { Home } from './pages/home/home';
|
||||||
|
import { MyActivity } from './pages/my-activity/my-activity';
|
||||||
|
import { Profile } from './pages/profile/profile';
|
||||||
|
import { Search } from './pages/search/search';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: '', component: Home },
|
||||||
|
{ path: 'ricerca', component: Search },
|
||||||
|
{ path: 'attivita/dettaglio/:idAttivita', component: Detail },
|
||||||
|
{ path: 'mie-attivita', component: MyActivity, canActivate: [authGuard] },
|
||||||
|
{ path: 'profilo', component: Profile, canActivate: [authGuard] },
|
||||||
|
{ path: 'nuova-attivita', component: ActivityForm, canActivate: [authGuard] },
|
||||||
|
{ path: 'modifica-attivita/:idAttivita', component: ActivityForm, canActivate: [authGuard] },
|
||||||
|
{ path: '**', redirectTo: '' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
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),
|
||||||
|
provideHttpClient(),
|
||||||
|
{ provide: Keycloak, useValue: { authenticated: false, tokenParsed: {} } },
|
||||||
|
{
|
||||||
|
provide: KEYCLOAK_EVENT_SIGNAL,
|
||||||
|
useValue: signal<KeycloakEvent>({ type: KeycloakEventType.Ready, args: true }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
import { Header } from './shared/header/header';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [RouterOutlet, Header],
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css',
|
||||||
|
})
|
||||||
|
export class App {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||||
|
import { AuthGuardData, createAuthGuard } from 'keycloak-angular';
|
||||||
|
|
||||||
|
export async function isAccessAllowed(
|
||||||
|
_route: ActivatedRouteSnapshot,
|
||||||
|
state: RouterStateSnapshot,
|
||||||
|
authData: AuthGuardData
|
||||||
|
): Promise<boolean | UrlTree> {
|
||||||
|
const { authenticated, keycloak } = authData;
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
await keycloak.login({ redirectUri: window.location.origin + state.url });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authGuard: CanActivateFn = createAuthGuard<CanActivateFn>(isAccessAllowed);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { EnvironmentProviders } from '@angular/core';
|
||||||
|
import { provideKeycloak, withAutoRefreshToken, AutoRefreshTokenService, UserActivityService } from 'keycloak-angular';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
export function provideKeycloakAngular(): EnvironmentProviders {
|
||||||
|
return provideKeycloak({
|
||||||
|
config: {
|
||||||
|
url: environment.keycloakBaseUrl,
|
||||||
|
realm: environment.keycloakRealm,
|
||||||
|
clientId: environment.keycloakClientId
|
||||||
|
},
|
||||||
|
initOptions: {
|
||||||
|
onLoad: 'check-sso',
|
||||||
|
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
|
||||||
|
redirectUri: window.location.origin + '/'
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
withAutoRefreshToken({
|
||||||
|
onInactivityTimeout: 'logout',
|
||||||
|
sessionTimeout: 300000
|
||||||
|
})
|
||||||
|
],
|
||||||
|
providers: [AutoRefreshTokenService, UserActivityService]
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export interface BaseTipologica {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stato extends BaseTipologica {}
|
||||||
|
|
||||||
|
export const STATO_BOZZA: Stato = { id: 'BO', nome: 'Bozza' };
|
||||||
|
export const STATO_PUBBLICATO: Stato = { id: 'PU', nome: 'Pubblicato' };
|
||||||
|
export const STATO_PRIVATO: Stato = { id: 'PR', nome: 'Privato' };
|
||||||
|
export const STATI: Stato[] = [STATO_BOZZA, STATO_PUBBLICATO, STATO_PRIVATO];
|
||||||
|
|
||||||
|
export interface TipoParagrafo extends BaseTipologica {}
|
||||||
|
|
||||||
|
export const TIPO_TITOLO: TipoParagrafo = { id: 'TITOLO', nome: 'Titolo' };
|
||||||
|
export const TIPO_PARAGRAFO: TipoParagrafo = { id: 'PARAGRAFO', nome: 'Paragrafo' };
|
||||||
|
|
||||||
|
export interface TipoCategoria extends BaseTipologica {}
|
||||||
|
|
||||||
|
export interface Branca {
|
||||||
|
id: number;
|
||||||
|
nome: string;
|
||||||
|
inizioEta?: number;
|
||||||
|
fineEta?: number;
|
||||||
|
colore?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Categoria {
|
||||||
|
id: number;
|
||||||
|
nome: string;
|
||||||
|
tipo?: TipoCategoria;
|
||||||
|
padre?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Materiale {
|
||||||
|
id: number;
|
||||||
|
nome: string;
|
||||||
|
proprieta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodoAnno {
|
||||||
|
id: number;
|
||||||
|
nome: string;
|
||||||
|
inizioMese?: number | null;
|
||||||
|
fineMese?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paragrafo {
|
||||||
|
id?: number;
|
||||||
|
corpo: string;
|
||||||
|
tipo: TipoParagrafo;
|
||||||
|
ordine: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attivita {
|
||||||
|
id?: number;
|
||||||
|
nome: string;
|
||||||
|
autore: string;
|
||||||
|
stato: Stato;
|
||||||
|
padre?: number | null;
|
||||||
|
paragrafoList: Paragrafo[];
|
||||||
|
brancaList: Branca[];
|
||||||
|
categoriaList: Categoria[];
|
||||||
|
materialeList: Materiale[];
|
||||||
|
periodoAnnoList: PeriodoAnno[];
|
||||||
|
dataCreazione?: string;
|
||||||
|
dataModifica?: string;
|
||||||
|
utenteModifica?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type GruppoFiltro = 'testo' | 'branca' | 'categoria' | 'materiale' | 'periodoAnno';
|
||||||
|
|
||||||
|
export interface FiltroAttivo {
|
||||||
|
id: number | null;
|
||||||
|
nome: string;
|
||||||
|
gruppo: GruppoFiltro;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corrisponde a SearchObjectDto lato backend: usato sia come voce inviata
|
||||||
|
* a POST /get/lista/search (in array) sia come elemento restituito dagli
|
||||||
|
* endpoint di autocomplete. */
|
||||||
|
export interface SearchObjectDto {
|
||||||
|
id: number | null;
|
||||||
|
nome: string | null;
|
||||||
|
gruppo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteGroup {
|
||||||
|
label: string;
|
||||||
|
objectsList: SearchObjectDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Attivita, Stato } from '../models/attivita.model';
|
||||||
|
import { SearchObjectDto } from '../models/search.model';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AttivitaService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly publicUrl = `${environment.apiUrl}/public/attivita`;
|
||||||
|
private readonly privateUrl = `${environment.apiUrl}/private/attivita`;
|
||||||
|
|
||||||
|
getListHome(): Observable<Attivita[]> {
|
||||||
|
return this.http.get<Attivita[]>(`${this.publicUrl}/get/lista/home`);
|
||||||
|
}
|
||||||
|
|
||||||
|
search(filtri: SearchObjectDto[]): Observable<Attivita[]> {
|
||||||
|
return this.http.post<Attivita[]>(`${this.publicUrl}/get/lista/search`, filtri);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOne(id: number): Observable<Attivita> {
|
||||||
|
return this.http.get<Attivita>(`${this.publicUrl}/get/one/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getListMy(): Observable<Attivita[]> {
|
||||||
|
return this.http.get<Attivita[]>(`${this.privateUrl}/get/lista/my`);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeStato(idAttivita: number, idStato: string): Observable<Stato> {
|
||||||
|
return this.http.get<Stato>(`${this.privateUrl}/change/stato/${idAttivita}/${idStato}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
save(attivita: Attivita): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.privateUrl}/save`, attivita);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { AutocompleteGroup, SearchObjectDto } from '../models/search.model';
|
||||||
|
|
||||||
|
const JSON_STRING_HEADERS = new HttpHeaders({ 'Content-Type': 'application/json' });
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AutocompleteService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly baseUrl = `${environment.apiUrl}/public/autocomplete`;
|
||||||
|
|
||||||
|
private postKeyword<T>(path: string, keyword: string): Observable<T> {
|
||||||
|
// Il backend si aspetta come body una stringa JSON nuda (es. "caccia"),
|
||||||
|
// non un oggetto: va serializzata esplicitamente per evitare che
|
||||||
|
// HttpClient la invii come text/plain non incapsulata.
|
||||||
|
return this.http.post<T>(`${this.baseUrl}${path}`, JSON.stringify(keyword), {
|
||||||
|
headers: JSON_STRING_HEADERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
search(keyword: string): Observable<AutocompleteGroup[]> {
|
||||||
|
return this.postKeyword<AutocompleteGroup[]>('/get/search', keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
branca(keyword: string): Observable<SearchObjectDto[]> {
|
||||||
|
return this.postKeyword<SearchObjectDto[]>('/get/branca', keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
categoria(keyword: string): Observable<SearchObjectDto[]> {
|
||||||
|
return this.postKeyword<SearchObjectDto[]>('/get/categoria', keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
materiale(keyword: string): Observable<SearchObjectDto[]> {
|
||||||
|
return this.postKeyword<SearchObjectDto[]>('/get/materiale', keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
periodoAnno(keyword: string): Observable<SearchObjectDto[]> {
|
||||||
|
return this.postKeyword<SearchObjectDto[]>('/get/periodoAnno', keyword);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import { FiltroAttivo } from '../models/search.model';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class NavigationService {
|
||||||
|
readonly filtri = signal<FiltroAttivo[]>([]);
|
||||||
|
readonly searchQuery = signal('');
|
||||||
|
|
||||||
|
addFiltro(filtro: FiltroAttivo): void {
|
||||||
|
const esiste = this.filtri().some(
|
||||||
|
(f) => f.gruppo === filtro.gruppo && f.id === filtro.id && f.nome === filtro.nome
|
||||||
|
);
|
||||||
|
if (esiste) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.filtri.update((lista) => [...lista, filtro]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFiltro(index: number): void {
|
||||||
|
this.filtri.update((lista) => lista.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.filtri.set([]);
|
||||||
|
this.searchQuery.set('');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export interface StatoStyle {
|
||||||
|
bg: string;
|
||||||
|
color: string;
|
||||||
|
border: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statoStyle(idStato: string): StatoStyle {
|
||||||
|
if (idStato === 'PU') {
|
||||||
|
return { bg: 'var(--color-primary-soft)', color: 'var(--color-primary-text)', border: 'var(--color-primary-soft-border)' };
|
||||||
|
}
|
||||||
|
if (idStato === 'PR') {
|
||||||
|
return { bg: 'var(--color-periodo-bg)', color: 'var(--color-periodo-text)', border: 'var(--color-periodo-border)' };
|
||||||
|
}
|
||||||
|
return { bg: 'var(--color-neutral-bg)', color: 'var(--color-neutral-text)', border: 'var(--color-neutral-border)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatData(data: string | undefined): string {
|
||||||
|
if (!data) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMarkdown(testo: string): { text: string; weight: string; style: string }[] {
|
||||||
|
const parti = (testo || '').split(/(\*\*[^*]+\*\*|\*[^*]+\*)/g).filter((p) => p.length);
|
||||||
|
if (!parti.length) {
|
||||||
|
return [{ text: '', weight: '400', style: 'normal' }];
|
||||||
|
}
|
||||||
|
return parti.map((p) => {
|
||||||
|
if (p.startsWith('**') && p.endsWith('**')) {
|
||||||
|
return { text: p.slice(2, -2), weight: '700', style: 'normal' };
|
||||||
|
}
|
||||||
|
if (p.startsWith('*') && p.endsWith('*')) {
|
||||||
|
return { text: p.slice(1, -1), weight: '400', style: 'italic' };
|
||||||
|
}
|
||||||
|
return { text: p, weight: '400', style: 'normal' };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
.wizard-page {
|
||||||
|
max-width: 760px;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titolo {
|
||||||
|
font-size: 26px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 18px 0 28px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-tab {
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-tab--attivo {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content--gap {
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--sm {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input--sm {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stato-choices {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stato-choice {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragrafo-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragrafo-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragrafo-tipo {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragrafo-azioni {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.azione {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.azione--danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formattazione {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmt-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmt-btn--italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragrafo-textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aggiungi-paragrafo {
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px dashed var(--color-border-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.riepilogo {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.riepilogo-nome {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 19px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.riepilogo-meta {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.riepilogo-linea {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<div class="page wizard-page">
|
||||||
|
<h1 class="titolo">{{ wizardTitle() }}</h1>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
@for (label of stepLabels; track $index) {
|
||||||
|
<div
|
||||||
|
class="step-tab"
|
||||||
|
[class.step-tab--attivo]="step() === $index"
|
||||||
|
(click)="setStep($index)"
|
||||||
|
>
|
||||||
|
{{ $index + 1 }}. {{ label }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (step() === 0) {
|
||||||
|
<div class="step-content">
|
||||||
|
<label class="field">
|
||||||
|
Nome attività
|
||||||
|
<input
|
||||||
|
class="text-input"
|
||||||
|
type="text"
|
||||||
|
[value]="nome()"
|
||||||
|
(input)="nome.set($any($event.target).value)"
|
||||||
|
placeholder="Es. Caccia al tesoro nel bosco"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
Stato iniziale
|
||||||
|
<div class="stato-choices">
|
||||||
|
@for (s of stati; track s.id) {
|
||||||
|
<div
|
||||||
|
class="stato-choice"
|
||||||
|
[class.stato-choice--attivo]="stato().id === s.id"
|
||||||
|
[style.background]="stato().id === s.id ? statoStile(s.id).bg : 'transparent'"
|
||||||
|
[style.color]="stato().id === s.id ? statoStile(s.id).color : 'var(--color-text-muted)'"
|
||||||
|
[style.border-color]="stato().id === s.id ? statoStile(s.id).border : 'var(--color-border)'"
|
||||||
|
(click)="selectStato(s)"
|
||||||
|
>
|
||||||
|
{{ s.nome }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (step() === 1) {
|
||||||
|
<div class="step-content step-content--gap">
|
||||||
|
<app-chip-field
|
||||||
|
label="Branca"
|
||||||
|
[chips]="brancaList()"
|
||||||
|
[(query)]="brancaQuery"
|
||||||
|
[suggestions]="brancaSuggestions()"
|
||||||
|
(select)="addBranca($event)"
|
||||||
|
(remove)="removeBranca($event)"
|
||||||
|
/>
|
||||||
|
<app-chip-field
|
||||||
|
label="Categoria"
|
||||||
|
[chips]="categoriaList()"
|
||||||
|
[(query)]="categoriaQuery"
|
||||||
|
[suggestions]="categoriaSuggestions()"
|
||||||
|
(select)="addCategoria($event)"
|
||||||
|
(remove)="removeCategoria($event)"
|
||||||
|
/>
|
||||||
|
<app-chip-field
|
||||||
|
label="Periodo dell'anno"
|
||||||
|
[chips]="periodoAnnoList()"
|
||||||
|
[(query)]="periodoQuery"
|
||||||
|
[suggestions]="periodoSuggestions()"
|
||||||
|
(select)="addPeriodo($event)"
|
||||||
|
(remove)="removePeriodo($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (step() === 2) {
|
||||||
|
<div class="step-content">
|
||||||
|
<app-chip-field
|
||||||
|
label="Materiale"
|
||||||
|
[chips]="materialeList()"
|
||||||
|
[(query)]="materialeQuery"
|
||||||
|
[suggestions]="materialeSuggestions()"
|
||||||
|
(select)="addMateriale($event)"
|
||||||
|
(remove)="removeMateriale($event)"
|
||||||
|
/>
|
||||||
|
@for (materiale of materialeList(); track materiale.id) {
|
||||||
|
<label class="field field--sm">
|
||||||
|
Note/proprietà per "{{ materiale.nome }}"
|
||||||
|
<input
|
||||||
|
class="text-input text-input--sm"
|
||||||
|
type="text"
|
||||||
|
[value]="materiale.proprieta || ''"
|
||||||
|
(input)="setMaterialeProprieta(materiale.id, $any($event.target).value)"
|
||||||
|
placeholder="es. quantità, note d'uso"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (step() === 3) {
|
||||||
|
<div class="step-content">
|
||||||
|
@for (paragrafo of paragrafoList(); track $index; let i = $index) {
|
||||||
|
<div class="paragrafo-card">
|
||||||
|
<div class="paragrafo-header">
|
||||||
|
<span class="paragrafo-tipo">{{ paragrafo.tipo.id === 'TITOLO' ? 'Titolo' : 'Paragrafo ' + i }}</span>
|
||||||
|
@if (paragrafo.tipo.id !== 'TITOLO') {
|
||||||
|
<div class="paragrafo-azioni">
|
||||||
|
<span class="azione" (click)="moveParagrafo(i, -1)">↑</span>
|
||||||
|
<span class="azione" (click)="moveParagrafo(i, 1)">↓</span>
|
||||||
|
<span class="azione azione--danger" (click)="removeParagrafo(i)">✕</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (paragrafo.tipo.id !== 'TITOLO') {
|
||||||
|
<div class="formattazione">
|
||||||
|
<span class="fmt-btn" (click)="wrapSelection(ta, i, '**')">B</span>
|
||||||
|
<span class="fmt-btn fmt-btn--italic" (click)="wrapSelection(ta, i, '*')">I</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<textarea
|
||||||
|
#ta
|
||||||
|
class="paragrafo-textarea"
|
||||||
|
[value]="paragrafo.corpo"
|
||||||
|
(input)="updateParagrafoCorpo(i, $any($event.target).value)"
|
||||||
|
[rows]="paragrafo.tipo.id === 'TITOLO' ? 1 : 3"
|
||||||
|
[placeholder]="paragrafo.tipo.id === 'TITOLO' ? 'Titolo dell\'attività' : 'Scrivi il contenuto di questo paragrafo...'"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="aggiungi-paragrafo" (click)="addParagrafo()">+ Aggiungi paragrafo</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (step() === 4) {
|
||||||
|
<div class="riepilogo">
|
||||||
|
<div class="riepilogo-nome">{{ nome() }}</div>
|
||||||
|
<div class="riepilogo-meta">Di {{ autore() }} · Stato: {{ stato().nome }}</div>
|
||||||
|
<div class="riepilogo-linea">{{ summaryLine() }}</div>
|
||||||
|
<div class="riepilogo-linea">
|
||||||
|
{{ nonTitoloCount() }} paragraf{{ nonTitoloCount() === 1 ? 'o' : 'i' }} oltre al titolo
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="wizard-nav">
|
||||||
|
<div class="btn-secondary" [style.visibility]="step() === 0 ? 'hidden' : 'visible'" (click)="prevStep()">
|
||||||
|
Indietro
|
||||||
|
</div>
|
||||||
|
@if (isLastStep()) {
|
||||||
|
<div class="btn-primary" (click)="salva()">Salva attività</div>
|
||||||
|
} @else {
|
||||||
|
<div class="btn-primary" (click)="nextStep()">Avanti</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Attivita,
|
||||||
|
Branca,
|
||||||
|
Categoria,
|
||||||
|
Materiale,
|
||||||
|
Paragrafo,
|
||||||
|
PeriodoAnno,
|
||||||
|
STATI,
|
||||||
|
STATO_BOZZA,
|
||||||
|
Stato,
|
||||||
|
TIPO_PARAGRAFO,
|
||||||
|
TIPO_TITOLO,
|
||||||
|
} from '../../core/models/attivita.model';
|
||||||
|
import { AttivitaService } from '../../core/services/attivita';
|
||||||
|
import { AutocompleteService } from '../../core/services/autocomplete';
|
||||||
|
import { statoStyle } from '../../core/utils/stato-style';
|
||||||
|
import { ChipField, ChipOption } from '../../shared/chip-field/chip-field';
|
||||||
|
|
||||||
|
const STEP_LABELS = ['Info base', 'Classificazione', 'Materiale', 'Paragrafi', 'Riepilogo'];
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-activity-form',
|
||||||
|
imports: [ChipField],
|
||||||
|
templateUrl: './activity-form.html',
|
||||||
|
styleUrl: './activity-form.css',
|
||||||
|
})
|
||||||
|
export class ActivityForm {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly attivitaService = inject(AttivitaService);
|
||||||
|
private readonly autocompleteService = inject(AutocompleteService);
|
||||||
|
private readonly keycloak = inject(Keycloak);
|
||||||
|
|
||||||
|
readonly editingId = signal<number | null>(null);
|
||||||
|
readonly step = signal(0);
|
||||||
|
readonly stepLabels = STEP_LABELS;
|
||||||
|
readonly stati = STATI;
|
||||||
|
|
||||||
|
readonly nome = signal('');
|
||||||
|
readonly autore = signal(this.nomeUtenteAutenticato());
|
||||||
|
readonly stato = signal<Stato>(STATO_BOZZA);
|
||||||
|
readonly brancaList = signal<Branca[]>([]);
|
||||||
|
readonly categoriaList = signal<Categoria[]>([]);
|
||||||
|
readonly periodoAnnoList = signal<PeriodoAnno[]>([]);
|
||||||
|
readonly materialeList = signal<Materiale[]>([]);
|
||||||
|
readonly paragrafoList = signal<Paragrafo[]>([{ corpo: '', tipo: TIPO_TITOLO, ordine: 0 }]);
|
||||||
|
|
||||||
|
readonly brancaQuery = signal('');
|
||||||
|
readonly categoriaQuery = signal('');
|
||||||
|
readonly periodoQuery = signal('');
|
||||||
|
readonly materialeQuery = signal('');
|
||||||
|
|
||||||
|
readonly brancaSuggestions = signal<ChipOption[]>([]);
|
||||||
|
readonly categoriaSuggestions = signal<ChipOption[]>([]);
|
||||||
|
readonly periodoSuggestions = signal<ChipOption[]>([]);
|
||||||
|
readonly materialeSuggestions = signal<ChipOption[]>([]);
|
||||||
|
|
||||||
|
readonly wizardTitle = computed(() => (this.editingId() ? 'Modifica attività' : 'Nuova attività'));
|
||||||
|
readonly isLastStep = computed(() => this.step() === 4);
|
||||||
|
readonly nonTitoloCount = computed(() => this.paragrafoList().filter((p) => p.tipo.id !== 'TITOLO').length);
|
||||||
|
readonly summaryLine = computed(() => {
|
||||||
|
const nomi = [...this.brancaList().map((b) => b.nome), ...this.categoriaList().map((c) => c.nome)];
|
||||||
|
return nomi.length > 0 ? nomi.join(' · ') : 'Nessuna classificazione selezionata';
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const idParam = this.route.snapshot.paramMap.get('idAttivita');
|
||||||
|
if (idParam) {
|
||||||
|
const id = Number(idParam);
|
||||||
|
this.editingId.set(id);
|
||||||
|
this.attivitaService.getOne(id).subscribe((attivita) => this.popolaForm(attivita));
|
||||||
|
}
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const query = this.brancaQuery();
|
||||||
|
const selezionati = new Set(this.brancaList().map((b) => b.id));
|
||||||
|
if (!query.trim()) {
|
||||||
|
this.brancaSuggestions.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.autocompleteService.branca(query).subscribe({
|
||||||
|
next: (r) => this.brancaSuggestions.set(this.toChipOptions(r, selezionati)),
|
||||||
|
error: () => this.brancaSuggestions.set([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const query = this.categoriaQuery();
|
||||||
|
const selezionati = new Set(this.categoriaList().map((c) => c.id));
|
||||||
|
if (!query.trim()) {
|
||||||
|
this.categoriaSuggestions.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.autocompleteService.categoria(query).subscribe({
|
||||||
|
next: (r) => this.categoriaSuggestions.set(this.toChipOptions(r, selezionati)),
|
||||||
|
error: () => this.categoriaSuggestions.set([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const query = this.periodoQuery();
|
||||||
|
const selezionati = new Set(this.periodoAnnoList().map((p) => p.id));
|
||||||
|
if (!query.trim()) {
|
||||||
|
this.periodoSuggestions.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.autocompleteService.periodoAnno(query).subscribe({
|
||||||
|
next: (r) => this.periodoSuggestions.set(this.toChipOptions(r, selezionati)),
|
||||||
|
error: () => this.periodoSuggestions.set([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const query = this.materialeQuery();
|
||||||
|
const selezionati = new Set(this.materialeList().map((m) => m.id));
|
||||||
|
if (!query.trim()) {
|
||||||
|
this.materialeSuggestions.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.autocompleteService.materiale(query).subscribe({
|
||||||
|
next: (r) => this.materialeSuggestions.set(this.toChipOptions(r, selezionati)),
|
||||||
|
error: () => this.materialeSuggestions.set([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChipOptions(
|
||||||
|
risultati: { id: number | null; nome: string | null }[],
|
||||||
|
esclusi: Set<number>
|
||||||
|
): ChipOption[] {
|
||||||
|
return risultati
|
||||||
|
.filter((r): r is { id: number; nome: string } => r.id !== null && r.nome !== null)
|
||||||
|
.filter((r) => !esclusi.has(r.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private nomeUtenteAutenticato(): string {
|
||||||
|
const token = this.keycloak.tokenParsed;
|
||||||
|
return (token?.['name'] as string) ?? (token?.['preferred_username'] as string) ?? (token?.['email'] as string) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private popolaForm(attivita: Attivita): void {
|
||||||
|
this.nome.set(attivita.nome);
|
||||||
|
this.autore.set(attivita.autore);
|
||||||
|
this.stato.set(attivita.stato);
|
||||||
|
this.brancaList.set([...attivita.brancaList]);
|
||||||
|
this.categoriaList.set([...attivita.categoriaList]);
|
||||||
|
this.periodoAnnoList.set([...attivita.periodoAnnoList]);
|
||||||
|
this.materialeList.set(attivita.materialeList.map((m) => ({ ...m })));
|
||||||
|
this.paragrafoList.set(attivita.paragrafoList.map((p) => ({ ...p })));
|
||||||
|
}
|
||||||
|
|
||||||
|
selectStato(stato: Stato): void {
|
||||||
|
this.stato.set(stato);
|
||||||
|
}
|
||||||
|
|
||||||
|
statoStile(idStato: string) {
|
||||||
|
return statoStyle(idStato);
|
||||||
|
}
|
||||||
|
|
||||||
|
addBranca(chip: ChipOption): void {
|
||||||
|
this.brancaList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
|
||||||
|
this.brancaQuery.set('');
|
||||||
|
}
|
||||||
|
removeBranca(chip: ChipOption): void {
|
||||||
|
this.brancaList.update((lista) => lista.filter((b) => b.id !== chip.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
addCategoria(chip: ChipOption): void {
|
||||||
|
this.categoriaList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
|
||||||
|
this.categoriaQuery.set('');
|
||||||
|
}
|
||||||
|
removeCategoria(chip: ChipOption): void {
|
||||||
|
this.categoriaList.update((lista) => lista.filter((c) => c.id !== chip.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
addPeriodo(chip: ChipOption): void {
|
||||||
|
this.periodoAnnoList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
|
||||||
|
this.periodoQuery.set('');
|
||||||
|
}
|
||||||
|
removePeriodo(chip: ChipOption): void {
|
||||||
|
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== chip.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
addMateriale(chip: ChipOption): void {
|
||||||
|
this.materialeList.update((lista) => [...lista, { id: chip.id, nome: chip.nome, proprieta: '' }]);
|
||||||
|
this.materialeQuery.set('');
|
||||||
|
}
|
||||||
|
removeMateriale(chip: ChipOption): void {
|
||||||
|
this.materialeList.update((lista) => lista.filter((m) => m.id !== chip.id));
|
||||||
|
}
|
||||||
|
setMaterialeProprieta(id: number, valore: string): void {
|
||||||
|
this.materialeList.update((lista) => lista.map((m) => (m.id === id ? { ...m, proprieta: valore } : m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
addParagrafo(): void {
|
||||||
|
this.paragrafoList.update((lista) => [
|
||||||
|
...lista,
|
||||||
|
{ corpo: '', tipo: TIPO_PARAGRAFO, ordine: lista.length },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
removeParagrafo(idx: number): void {
|
||||||
|
this.paragrafoList.update((lista) =>
|
||||||
|
lista.filter((_, i) => i !== idx).map((p, i) => ({ ...p, ordine: i }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
moveParagrafo(idx: number, direzione: -1 | 1): void {
|
||||||
|
this.paragrafoList.update((lista) => {
|
||||||
|
const j = idx + direzione;
|
||||||
|
if (j < 1 || j >= lista.length) {
|
||||||
|
return lista;
|
||||||
|
}
|
||||||
|
const copia = [...lista];
|
||||||
|
[copia[idx], copia[j]] = [copia[j], copia[idx]];
|
||||||
|
return copia.map((p, i) => ({ ...p, ordine: i }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateParagrafoCorpo(idx: number, valore: string): void {
|
||||||
|
this.paragrafoList.update((lista) => lista.map((p, i) => (i === idx ? { ...p, corpo: valore } : p)));
|
||||||
|
}
|
||||||
|
wrapSelection(textarea: HTMLTextAreaElement, idx: number, marker: string): void {
|
||||||
|
const valore = this.paragrafoList()[idx].corpo || '';
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selezione = valore.slice(start, end) || 'testo';
|
||||||
|
const nuovoValore = valore.slice(0, start) + marker + selezione + marker + valore.slice(end);
|
||||||
|
this.updateParagrafoCorpo(idx, nuovoValore);
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.selectionStart = start + marker.length;
|
||||||
|
textarea.selectionEnd = start + marker.length + selezione.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextStep(): void {
|
||||||
|
this.step.update((s) => Math.min(4, s + 1));
|
||||||
|
}
|
||||||
|
prevStep(): void {
|
||||||
|
this.step.update((s) => Math.max(0, s - 1));
|
||||||
|
}
|
||||||
|
setStep(n: number): void {
|
||||||
|
this.step.set(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
salva(): void {
|
||||||
|
const attivita: Attivita = {
|
||||||
|
id: this.editingId() ?? undefined,
|
||||||
|
nome: this.nome(),
|
||||||
|
autore: this.autore(),
|
||||||
|
stato: this.stato(),
|
||||||
|
brancaList: this.brancaList(),
|
||||||
|
categoriaList: this.categoriaList(),
|
||||||
|
periodoAnnoList: this.periodoAnnoList(),
|
||||||
|
materialeList: this.materialeList(),
|
||||||
|
paragrafoList: this.paragrafoList(),
|
||||||
|
};
|
||||||
|
this.attivitaService.save(attivita).subscribe(() => this.router.navigate(['/mie-attivita']));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
.page {
|
||||||
|
max-width: 760px;
|
||||||
|
padding-top: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branca-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branca-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branca-nomi {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titolo {
|
||||||
|
font-size: 32px;
|
||||||
|
margin: 6px 0 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stato {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--categoria {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--periodo {
|
||||||
|
background: var(--color-periodo-bg);
|
||||||
|
border: 1px solid var(--color-periodo-border);
|
||||||
|
color: var(--color-periodo-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.materiale-box {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materiale-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materiale-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materiale-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materiale-icon {
|
||||||
|
color: var(--color-primary-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.materiale-nome {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materiale-proprieta {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragrafi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragrafo-titolo {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
border-bottom: 2px solid var(--color-primary);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragrafo-corpo {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@if (attivita(); as a) {
|
||||||
|
<div class="page">
|
||||||
|
<div class="branca-row">
|
||||||
|
@for (branca of a.brancaList; track branca.id) {
|
||||||
|
<span class="branca-dot" [style.background]="branca.colore" [title]="branca.nome"></span>
|
||||||
|
}
|
||||||
|
<span class="branca-nomi">{{ brancaNomi() }}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="titolo">{{ a.nome }}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
Di {{ a.autore }} · Aggiornata il {{ dataModificaFmt() }} ·
|
||||||
|
<span class="stato" [style.color]="statoTextColor()">{{ a.stato.nome }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="chips-row">
|
||||||
|
@for (categoria of a.categoriaList; track categoria.id) {
|
||||||
|
<span class="chip chip--categoria">{{ categoria.nome }}</span>
|
||||||
|
}
|
||||||
|
@for (periodo of a.periodoAnnoList; track periodo.id) {
|
||||||
|
<span class="chip chip--periodo">{{ periodo.nome }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (a.materialeList.length > 0) {
|
||||||
|
<div class="materiale-box">
|
||||||
|
<div class="materiale-title">Materiale occorrente</div>
|
||||||
|
<div class="materiale-list">
|
||||||
|
@for (materiale of a.materialeList; track materiale.id) {
|
||||||
|
<div class="materiale-item">
|
||||||
|
<span class="materiale-icon">🧰</span>
|
||||||
|
<span class="materiale-nome">{{ materiale.nome }}</span>
|
||||||
|
@if (materiale.proprieta) {
|
||||||
|
<span class="materiale-proprieta">— {{ materiale.proprieta }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="paragrafi">
|
||||||
|
@for (paragrafo of paragrafiOrdinati(); track paragrafo.id) {
|
||||||
|
@if (isTitolo(paragrafo)) {
|
||||||
|
<h2 class="paragrafo-titolo">{{ paragrafo.corpo }}</h2>
|
||||||
|
} @else {
|
||||||
|
<p class="paragrafo-corpo">
|
||||||
|
@for (token of tokens(paragrafo); track $index) {
|
||||||
|
<span [style.font-weight]="token.weight" [style.font-style]="token.style">{{ token.text }}</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!attivita() && !caricamento()) {
|
||||||
|
<div class="page empty-state">Attività non trovata.</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
|
import { Attivita, Paragrafo } from '../../core/models/attivita.model';
|
||||||
|
import { AttivitaService } from '../../core/services/attivita';
|
||||||
|
import { formatData, parseMarkdown, statoStyle } from '../../core/utils/stato-style';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-detail',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './detail.html',
|
||||||
|
styleUrl: './detail.css',
|
||||||
|
})
|
||||||
|
export class Detail {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly attivitaService = inject(AttivitaService);
|
||||||
|
|
||||||
|
readonly attivita = signal<Attivita | null>(null);
|
||||||
|
readonly caricamento = signal(true);
|
||||||
|
|
||||||
|
readonly dataModificaFmt = computed(() => formatData(this.attivita()?.dataModifica));
|
||||||
|
readonly statoTextColor = computed(() => statoStyle(this.attivita()?.stato.id ?? 'BO').color);
|
||||||
|
readonly brancaNomi = computed(() => (this.attivita()?.brancaList ?? []).map((b) => b.nome).join(', '));
|
||||||
|
readonly paragrafiOrdinati = computed(() =>
|
||||||
|
[...(this.attivita()?.paragrafoList ?? [])].sort((a, b) => a.ordine - b.ordine)
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const idAttivita = Number(this.route.snapshot.paramMap.get('idAttivita'));
|
||||||
|
this.attivitaService.getOne(idAttivita).subscribe({
|
||||||
|
next: (attivita) => {
|
||||||
|
this.attivita.set(attivita);
|
||||||
|
this.caricamento.set(false);
|
||||||
|
},
|
||||||
|
error: () => this.caricamento.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isTitolo(paragrafo: Paragrafo): boolean {
|
||||||
|
return paragrafo.tipo.id === 'TITOLO';
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens(paragrafo: Paragrafo) {
|
||||||
|
return parseMarkdown(paragrafo.corpo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Attività pubblicate</h1>
|
||||||
|
<a class="search-bar" routerLink="/ricerca">
|
||||||
|
<span>🔍</span><span>Cerca un'attività...</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!caricamento() && attivitaList().length > 0) {
|
||||||
|
<div class="grid">
|
||||||
|
@for (attivita of attivitaList(); track attivita.id) {
|
||||||
|
<app-activity-card [attivita]="attivita" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!caricamento() && attivitaList().length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">🏕️</div>
|
||||||
|
<div class="empty-title">Nessuna attività pubblicata</div>
|
||||||
|
<div class="empty-subtitle">Le attività pubblicate dai capi compariranno qui.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { Attivita } from '../../core/models/attivita.model';
|
||||||
|
import { AttivitaService } from '../../core/services/attivita';
|
||||||
|
import { ActivityCard } from '../../shared/activity-card/activity-card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
imports: [RouterLink, ActivityCard],
|
||||||
|
templateUrl: './home.html',
|
||||||
|
styleUrl: './home.css',
|
||||||
|
})
|
||||||
|
export class Home {
|
||||||
|
private readonly attivitaService = inject(AttivitaService);
|
||||||
|
|
||||||
|
readonly attivitaList = signal<Attivita[]>([]);
|
||||||
|
readonly caricamento = signal(true);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.attivitaService.getListHome().subscribe({
|
||||||
|
next: (lista) => {
|
||||||
|
this.attivitaList.set(lista);
|
||||||
|
this.caricamento.set(false);
|
||||||
|
},
|
||||||
|
error: () => this.caricamento.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.lista {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.riga {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.riga-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.riga-titolo {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.riga-data {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stato-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stato-option {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.riga-modifica {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title page-title--sm">Le mie attività</h1>
|
||||||
|
<a class="btn-primary" routerLink="/nuova-attivita">+ Nuova attività</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (attivitaList().length > 0) {
|
||||||
|
<div class="lista">
|
||||||
|
@for (attivita of attivitaList(); track attivita.id) {
|
||||||
|
<div class="riga">
|
||||||
|
<div class="riga-info">
|
||||||
|
<a class="riga-titolo" [routerLink]="['/attivita/dettaglio', attivita.id]">{{ attivita.nome }}</a>
|
||||||
|
<div class="riga-data">Aggiornata il {{ dataModificaFmt(attivita) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stato-options">
|
||||||
|
@for (stato of stati; track stato.id) {
|
||||||
|
<div
|
||||||
|
class="stato-option"
|
||||||
|
[class.stato-option--attivo]="isStatoAttivo(attivita, stato)"
|
||||||
|
[style.background]="isStatoAttivo(attivita, stato) ? stileStato(stato.id).bg : 'transparent'"
|
||||||
|
[style.color]="isStatoAttivo(attivita, stato) ? stileStato(stato.id).color : 'var(--color-text-muted)'"
|
||||||
|
[style.border-color]="isStatoAttivo(attivita, stato) ? stileStato(stato.id).border : 'var(--color-border)'"
|
||||||
|
(click)="cambiaStato(attivita, stato)"
|
||||||
|
>
|
||||||
|
{{ stato.nome }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="riga-modifica" (click)="modifica(attivita)">Modifica</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (!caricamento()) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📋</div>
|
||||||
|
<div class="empty-title">Non hai ancora creato attività</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { Attivita, STATI, Stato } from '../../core/models/attivita.model';
|
||||||
|
import { AttivitaService } from '../../core/services/attivita';
|
||||||
|
import { formatData, statoStyle } from '../../core/utils/stato-style';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-my-activity',
|
||||||
|
imports: [RouterLink],
|
||||||
|
templateUrl: './my-activity.html',
|
||||||
|
styleUrl: './my-activity.css',
|
||||||
|
})
|
||||||
|
export class MyActivity {
|
||||||
|
private readonly attivitaService = inject(AttivitaService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
readonly attivitaList = signal<Attivita[]>([]);
|
||||||
|
readonly caricamento = signal(true);
|
||||||
|
readonly stati = STATI;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.caricaLista();
|
||||||
|
}
|
||||||
|
|
||||||
|
private caricaLista(): void {
|
||||||
|
this.attivitaService.getListMy().subscribe({
|
||||||
|
next: (lista) => {
|
||||||
|
this.attivitaList.set(lista);
|
||||||
|
this.caricamento.set(false);
|
||||||
|
},
|
||||||
|
error: () => this.caricamento.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dataModificaFmt(attivita: Attivita): string {
|
||||||
|
return formatData(attivita.dataModifica);
|
||||||
|
}
|
||||||
|
|
||||||
|
stileStato(idStato: string) {
|
||||||
|
return statoStyle(idStato);
|
||||||
|
}
|
||||||
|
|
||||||
|
isStatoAttivo(attivita: Attivita, stato: Stato): boolean {
|
||||||
|
return attivita.stato.id === stato.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
cambiaStato(attivita: Attivita, stato: Stato): void {
|
||||||
|
if (!attivita.id || attivita.stato.id === stato.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.attivitaService.changeStato(attivita.id, stato.id).subscribe(() => this.caricaLista());
|
||||||
|
}
|
||||||
|
|
||||||
|
modifica(attivita: Attivita): void {
|
||||||
|
this.router.navigate(['/modifica-attivita', attivita.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.profile-page {
|
||||||
|
max-width: 520px;
|
||||||
|
padding-top: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
color: var(--color-primary-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titolo {
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sottotitolo {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<div class="page profile-page">
|
||||||
|
<div class="avatar">👤</div>
|
||||||
|
<h1 class="titolo">Il tuo profilo</h1>
|
||||||
|
<p class="sottotitolo">
|
||||||
|
La gestione account arriverà con l'autenticazione. Per ora, accedi alle tue attività qui sotto.
|
||||||
|
</p>
|
||||||
|
<a class="btn-primary" routerLink="/mie-attivita">Vai a Le mie attività</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-profile',
|
||||||
|
imports: [RouterLink],
|
||||||
|
templateUrl: './profile.html',
|
||||||
|
styleUrl: './profile.css',
|
||||||
|
})
|
||||||
|
export class Profile {}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow-menu);
|
||||||
|
z-index: 5;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-group-label {
|
||||||
|
padding: 10px 16px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item {
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item:hover {
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 16px 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border: 1px solid var(--color-primary-soft-border);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-remove {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<div class="page">
|
||||||
|
<h1 class="page-title">Cerca attività</h1>
|
||||||
|
|
||||||
|
<div class="search-input-wrap">
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
type="text"
|
||||||
|
[value]="searchQuery()"
|
||||||
|
(input)="onQueryChange($event)"
|
||||||
|
(keydown)="onQueryKeyDown($event)"
|
||||||
|
placeholder="Cerca per nome, branca, categoria, materiale, periodo..."
|
||||||
|
/>
|
||||||
|
@if (showSuggestions()) {
|
||||||
|
<div class="suggestions">
|
||||||
|
@for (group of suggestionGroups(); track group.label) {
|
||||||
|
<div class="suggestion-group-label">{{ group.label }}</div>
|
||||||
|
@for (suggestion of group.objectsList; track suggestion.id) {
|
||||||
|
<div class="suggestion-item" (click)="selectSuggestion(suggestion)">
|
||||||
|
{{ suggestion.nome }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="active-filters">
|
||||||
|
@for (filtro of activeFilters(); track $index) {
|
||||||
|
<div class="filter-chip">
|
||||||
|
<span>{{ filtroLabel(filtro.gruppo) }}: {{ filtro.nome }}</span>
|
||||||
|
<span class="filter-remove" (click)="removeFiltro($index)">×</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-label">{{ resultsLabel() }}</div>
|
||||||
|
|
||||||
|
@if (results().length > 0) {
|
||||||
|
<div class="grid">
|
||||||
|
@for (attivita of results(); track attivita.id) {
|
||||||
|
<app-activity-card [attivita]="attivita" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="empty-state">Nessun risultato con questi filtri.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import { Attivita } from '../../core/models/attivita.model';
|
||||||
|
import { AutocompleteGroup, GruppoFiltro, SearchObjectDto } from '../../core/models/search.model';
|
||||||
|
import { AttivitaService } from '../../core/services/attivita';
|
||||||
|
import { AutocompleteService } from '../../core/services/autocomplete';
|
||||||
|
import { NavigationService } from '../../core/services/navigation';
|
||||||
|
import { ActivityCard } from '../../shared/activity-card/activity-card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-search',
|
||||||
|
imports: [ActivityCard],
|
||||||
|
templateUrl: './search.html',
|
||||||
|
styleUrl: './search.css',
|
||||||
|
})
|
||||||
|
export class Search {
|
||||||
|
private readonly attivitaService = inject(AttivitaService);
|
||||||
|
private readonly autocompleteService = inject(AutocompleteService);
|
||||||
|
readonly navigationService = inject(NavigationService);
|
||||||
|
|
||||||
|
readonly searchQuery = this.navigationService.searchQuery;
|
||||||
|
readonly activeFilters = this.navigationService.filtri;
|
||||||
|
|
||||||
|
readonly suggestionGroups = signal<AutocompleteGroup[]>([]);
|
||||||
|
readonly showSuggestions = computed(() => this.suggestionGroups().length > 0);
|
||||||
|
|
||||||
|
readonly results = signal<Attivita[]>([]);
|
||||||
|
readonly resultsLabel = computed(() => {
|
||||||
|
const n = this.results().length;
|
||||||
|
return `${n} risultat${n === 1 ? 'o' : 'i'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const filtri: SearchObjectDto[] = this.activeFilters();
|
||||||
|
this.attivitaService.search(filtri).subscribe({
|
||||||
|
next: (lista) => this.results.set(lista),
|
||||||
|
error: () => this.results.set([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onQueryChange(event: Event): void {
|
||||||
|
const value = (event.target as HTMLInputElement).value;
|
||||||
|
this.searchQuery.set(value);
|
||||||
|
if (!value.trim()) {
|
||||||
|
this.suggestionGroups.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.autocompleteService.search(value).subscribe({
|
||||||
|
next: (groups) => this.suggestionGroups.set(groups),
|
||||||
|
error: () => this.suggestionGroups.set([]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onQueryKeyDown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Enter' && this.searchQuery().trim()) {
|
||||||
|
this.navigationService.addFiltro({ id: null, nome: this.searchQuery().trim(), gruppo: 'testo' });
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.suggestionGroups.set([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectSuggestion(suggestion: SearchObjectDto): void {
|
||||||
|
const nome = suggestion.nome ?? this.searchQuery().trim();
|
||||||
|
if (!nome) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.navigationService.addFiltro({
|
||||||
|
id: suggestion.id,
|
||||||
|
nome,
|
||||||
|
gruppo: suggestion.gruppo as GruppoFiltro,
|
||||||
|
});
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.suggestionGroups.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFiltro(index: number): void {
|
||||||
|
this.navigationService.removeFiltro(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
filtroLabel(gruppo: string): string {
|
||||||
|
const etichette: Record<string, string> = {
|
||||||
|
testo: 'Testo',
|
||||||
|
branca: 'Branca',
|
||||||
|
categoria: 'Categoria',
|
||||||
|
materiale: 'Materiale',
|
||||||
|
periodoAnno: 'Periodo',
|
||||||
|
};
|
||||||
|
return etichette[gruppo] ?? gruppo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
.card {
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--color-text);
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branca-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stato-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-preview {
|
||||||
|
font-size: 14px;
|
||||||
|
color: oklch(38% 0.02 55);
|
||||||
|
line-height: 1.5;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-header);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<a class="card" [routerLink]="['/attivita/dettaglio', attivita().id]">
|
||||||
|
<div class="card-top">
|
||||||
|
@for (branca of attivita().brancaList; track branca.id) {
|
||||||
|
<span class="branca-dot" [style.background]="branca.colore" [title]="branca.nome"></span>
|
||||||
|
}
|
||||||
|
<span
|
||||||
|
class="stato-badge"
|
||||||
|
[style.background]="stile().bg"
|
||||||
|
[style.color]="stile().color"
|
||||||
|
>
|
||||||
|
{{ attivita().stato.nome }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-title">{{ attivita().nome }}</div>
|
||||||
|
<div class="card-meta">Di {{ attivita().autore }} · {{ dataModificaFmt() }}</div>
|
||||||
|
<div class="card-preview">{{ preview() }}</div>
|
||||||
|
<div class="card-chips">
|
||||||
|
@for (categoria of attivita().categoriaList; track categoria.id) {
|
||||||
|
<span class="chip">{{ categoria.nome }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { Attivita } from '../../core/models/attivita.model';
|
||||||
|
import { formatData, statoStyle } from '../../core/utils/stato-style';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-activity-card',
|
||||||
|
imports: [RouterLink],
|
||||||
|
templateUrl: './activity-card.html',
|
||||||
|
styleUrl: './activity-card.css',
|
||||||
|
})
|
||||||
|
export class ActivityCard {
|
||||||
|
readonly attivita = input.required<Attivita>();
|
||||||
|
|
||||||
|
readonly dataModificaFmt = computed(() => formatData(this.attivita().dataModifica));
|
||||||
|
|
||||||
|
readonly preview = computed(() => {
|
||||||
|
const paragrafi = this.attivita().paragrafoList ?? [];
|
||||||
|
const paragrafo = paragrafi.find((p) => p.tipo.id === 'PARAGRAFO');
|
||||||
|
const titolo = paragrafi.find((p) => p.tipo.id === 'TITOLO');
|
||||||
|
return paragrafo?.corpo ?? titolo?.corpo ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly stile = computed(() => statoStyle(this.attivita().stato.id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
.chip-field {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow-menu);
|
||||||
|
z-index: 4;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item {
|
||||||
|
padding: 9px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item:hover {
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border: 1px solid var(--color-primary-soft-border);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-remove {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="chip-field">
|
||||||
|
<label class="field-label">{{ label() }}</label>
|
||||||
|
<div class="field-input-wrap">
|
||||||
|
<input
|
||||||
|
class="field-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Cerca..."
|
||||||
|
[value]="query()"
|
||||||
|
(input)="onQueryInput($event)"
|
||||||
|
/>
|
||||||
|
@if (suggestions().length > 0) {
|
||||||
|
<div class="suggestions">
|
||||||
|
@for (suggestion of suggestions(); track suggestion.id) {
|
||||||
|
<div class="suggestion-item" (click)="select.emit(suggestion)">{{ suggestion.nome }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="chips">
|
||||||
|
@for (chip of chips(); track chip.id) {
|
||||||
|
<div class="chip">
|
||||||
|
<span>{{ chip.nome }}</span>
|
||||||
|
<span class="chip-remove" (click)="remove.emit(chip)">×</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Component, input, model, output } from '@angular/core';
|
||||||
|
|
||||||
|
export interface ChipOption {
|
||||||
|
id: number;
|
||||||
|
nome: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chip-field',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './chip-field.html',
|
||||||
|
styleUrl: './chip-field.css',
|
||||||
|
})
|
||||||
|
export class ChipField {
|
||||||
|
readonly label = input.required<string>();
|
||||||
|
readonly chips = input.required<ChipOption[]>();
|
||||||
|
readonly suggestions = input<ChipOption[]>([]);
|
||||||
|
readonly query = model('');
|
||||||
|
|
||||||
|
readonly remove = output<ChipOption>();
|
||||||
|
readonly select = output<ChipOption>();
|
||||||
|
|
||||||
|
onQueryInput(event: Event): void {
|
||||||
|
this.query.set((event.target as HTMLInputElement).value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
.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-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand--right {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
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-weight: 700;
|
||||||
|
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: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item--active {
|
||||||
|
color: var(--color-primary-text-strong);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
@if (isDetailOrForm()) {
|
||||||
|
<div class="header header--back">
|
||||||
|
<div class="back-link" (click)="back()">
|
||||||
|
<span class="back-arrow">←</span>
|
||||||
|
<span>Indietro</span>
|
||||||
|
</div>
|
||||||
|
<div class="brand brand--right">
|
||||||
|
<div class="logo">S</div>
|
||||||
|
<span class="brand-name">Scouthub</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="header header--nav">
|
||||||
|
<a class="brand" routerLink="/">
|
||||||
|
<div class="logo">S</div>
|
||||||
|
<span class="brand-name">Scouthub</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-items">
|
||||||
|
@for (item of navItems; track item.path) {
|
||||||
|
<a
|
||||||
|
class="nav-item"
|
||||||
|
[class.nav-item--active]="isActive(item.path)"
|
||||||
|
[routerLink]="item.path"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
<div class="auth-section">
|
||||||
|
@if (isAuthenticated()) {
|
||||||
|
<span class="auth-name">{{ displayName() }}</span>
|
||||||
|
<div class="btn-secondary btn-secondary--sm" (click)="logout()">Esci</div>
|
||||||
|
} @else {
|
||||||
|
<div class="btn-secondary btn-secondary--sm" (click)="login()">Accedi</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { Component, computed, 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';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
|
||||||
|
readonly currentUrl = signal(this.router.url);
|
||||||
|
|
||||||
|
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 isDetailOrForm = computed(() => {
|
||||||
|
const url = this.currentUrl();
|
||||||
|
return url.startsWith('/attivita/dettaglio') || url.startsWith('/nuova-attivita') || url.startsWith('/modifica-attivita');
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly navItems: NavItem[] = [
|
||||||
|
{ path: '/', label: 'Home' },
|
||||||
|
{ path: '/ricerca', label: 'Cerca' },
|
||||||
|
{ path: '/mie-attivita', label: 'Le mie attività' },
|
||||||
|
{ path: '/profilo', label: 'Profilo' },
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => {
|
||||||
|
this.currentUrl.set(this.router.url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(path: string): boolean {
|
||||||
|
return path === '/' ? this.currentUrl() === '/' : 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 + '/' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiUrl: 'http://localhost:8080',
|
||||||
|
keycloakBaseUrl: 'http://localhost:8081',
|
||||||
|
keycloakRealm: 'scouthub',
|
||||||
|
keycloakClientId: 'scouthub-frontend',
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiUrl: 'http://localhost:8080',
|
||||||
|
keycloakBaseUrl: 'http://localhost:8081',
|
||||||
|
keycloakRealm: 'scouthub',
|
||||||
|
keycloakClientId: 'scouthub-frontend',
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>ScouthubAttivitaFe</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
bootstrapApplication(App, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
@import url('https://fonts.cdnfonts.com/css/opendyslexic');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: oklch(96% 0.018 75);
|
||||||
|
--color-surface: oklch(99% 0.008 75);
|
||||||
|
--color-header: oklch(94% 0.02 75);
|
||||||
|
--color-border: oklch(87% 0.02 70);
|
||||||
|
--color-border-strong: oklch(80% 0.02 70);
|
||||||
|
--color-text: oklch(24% 0.02 55);
|
||||||
|
--color-text-muted: oklch(48% 0.02 55);
|
||||||
|
--color-primary: oklch(63% 0.19 40);
|
||||||
|
--color-primary-hover: oklch(56% 0.19 40);
|
||||||
|
--color-primary-soft: oklch(93% 0.06 45);
|
||||||
|
--color-primary-soft-border: oklch(80% 0.09 45);
|
||||||
|
--color-primary-text: oklch(40% 0.15 40);
|
||||||
|
--color-primary-text-strong: oklch(48% 0.17 40);
|
||||||
|
--color-periodo-bg: oklch(93% 0.02 260);
|
||||||
|
--color-periodo-border: oklch(80% 0.04 260);
|
||||||
|
--color-periodo-text: oklch(35% 0.05 260);
|
||||||
|
--color-danger: oklch(50% 0.15 25);
|
||||||
|
--color-neutral-bg: oklch(92% 0.005 60);
|
||||||
|
--color-neutral-text: oklch(45% 0.02 55);
|
||||||
|
--color-neutral-border: oklch(85% 0.01 60);
|
||||||
|
--shadow-menu: 0 8px 24px oklch(30% 0.02 60 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-bg);
|
||||||
|
font-family: 'Open Dyslexic', 'Segoe UI', sans-serif;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: oklch(63% 0.19 40 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title--sm {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 11px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user