Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s

Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
This commit is contained in:
Lorenzo Sanesi
2026-10-20 09:30:48 +02:00
co-authored by Claude Opus 5.5
commit b83ce039fb
505 changed files with 84544 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"private": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/styles.scss"],
"stylePreprocessorOptions": {
"includePaths": ["../../packages/styles"]
}
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "400kB",
"maximumError": "600kB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kB",
"maximumError": "10kB"
}
],
"outputHashing": "all",
"optimization": {
"scripts": true,
"styles": { "minify": true, "inlineCritical": false },
"fonts": true
}
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "private:build:production"
},
"development": {
"buildTarget": "private:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@iti/private",
"version": "0.0.0",
"private": true,
"description": "Area riservata ITI TECHNOLOGY: clienti, installatori, admin (Angular SPA)",
"type": "module",
"scripts": {
"postinstall": "node scripts/vendor-assets.mjs",
"predev": "node scripts/vendor-assets.mjs",
"dev": "ng serve --port 4300",
"prebuild": "node scripts/vendor-assets.mjs",
"build": "ng build",
"test": "ng test --watch=false",
"typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.spec.json --noEmit",
"ng": "ng"
},
"dependencies": {
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"@iti/shared": "*",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"leaflet": "^1.9.4"
},
"devDependencies": {
"@angular/build": "^22.2.1",
"@angular/cli": "^22.2.1",
"@angular/compiler-cli": "^22.2.0",
"jsdom": "^30.0.0",
"vitest": "^5.0.0",
"@types/leaflet": "^1.9.22"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ITI TECHNOLOGY">
<circle cx="44" cy="18" r="10" fill="#f2a900"/>
<path d="M28 6C28 6 10 28 10 40a18 18 0 0 0 36 0C46 28 28 6 28 6z" fill="#0b5c7a"/>
<path d="M20 42a8 8 0 0 0 8 8" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ITI TECHNOLOGY">
<circle cx="44" cy="18" r="10" fill="#f2a900"/>
<path d="M28 6C28 6 10 28 10 40a18 18 0 0 0 36 0C46 28 28 6 28 6z" fill="#0b5c7a"/>
<path d="M20 42a8 8 0 0 0 8 8" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

+17
View File
@@ -0,0 +1,17 @@
// Copia in public/vendor/ gli asset statici di librerie npm caricati a runtime (oggi: CSS e immagini di Leaflet,
// usati dal selettore di posizione del profilo installatore). public/vendor/ è generata e ignorata da git. Parte in postinstall, predev e prebuild.
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = dirname(dirname(fileURLToPath(import.meta.url)))
const require = createRequire(import.meta.url)
const leafletDist = dirname(require.resolve('leaflet/dist/leaflet.css'))
const target = join(root, 'public', 'vendor', 'leaflet')
mkdirSync(join(target, 'images'), { recursive: true })
copyFileSync(join(leafletDist, 'leaflet.css'), join(target, 'leaflet.css'))
const images = readdirSync(join(leafletDist, 'images')).filter((f) => f.endsWith('.png'))
for (const f of images) copyFileSync(join(leafletDist, 'images', f), join(target, 'images', f))
console.log(`Asset vendor: leaflet.css + ${images.length} immagini in public/vendor/leaflet`)
+23
View File
@@ -0,0 +1,23 @@
import { registerLocaleData } from '@angular/common'
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'
import localeIt from '@angular/common/locales/it'
import { type ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core'
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router'
import { routes } from './app.routes'
import { type AppConfig, APP_CONFIG } from './core/app-config'
import { authInterceptor } from './core/auth.interceptor'
registerLocaleData(localeIt)
/** Configurazione dell'app (SPA zoneless, nessun SSR), con la configurazione runtime letta da `config.json`. */
export function appConfig(config: AppConfig): ApplicationConfig {
return {
providers: [
provideBrowserGlobalErrorListeners(),
{ provide: LOCALE_ID, useValue: 'it-IT' },
{ provide: APP_CONFIG, useValue: config },
provideRouter(routes, withComponentInputBinding(), withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
],
}
}
+106
View File
@@ -0,0 +1,106 @@
import type { Routes, UrlMatchResult, UrlSegment } from '@angular/router'
import { authGuard, guestGuard, roleGuard } from './core/auth.guards'
import { CUSTOMER_AREA_ROLES } from './core/roles'
import { AuthLayout } from './layout/auth-layout'
import { Shell } from './layout/shell'
/** Sezioni dell'area clienti: privati, aziende e installatori (che hanno anche la loro area). */
const customer = [roleGuard(...CUSTOMER_AREA_ROLES)]
const AUTH_PAGES = new Set(['login', 'registrazione', 'forgot-password', 'reset-password', 'verify-email'])
/**
* Il layout di accesso vale solo per le sue pagine. Con `path: ''` Angular lo attiverebbe anche per `/`
* (rotta vuota con figli: corrisponde all'URL vuoto anche se nessun figlio corrisponde), con una pagina vuota.
*/
function authPagesMatcher(segments: UrlSegment[]): UrlMatchResult | null {
return segments[0] && AUTH_PAGES.has(segments[0].path) ? { consumed: [] } : null
}
/**
* - Pagine di accesso nel layout semplice. Login, registrazione e password dimenticata solo da anonimi
* (`guestGuard`); reset password e verifica email anche con una sessione aperta.
* I percorsi `/forgot-password`, `/reset-password`, `/verify-email` sono quelli dei link delle email dell'API;
* `/login?redirect=` e `/registrazione?email=` sono il contratto con il sito pubblico (checkout).
* - Area autenticata nel layout con menu (`authGuard`). Ogni sezione è riservata ai ruoli che la usano
* (`roleGuard`, `canMatch`): un ruolo non ammesso viene portato alla propria pagina iniziale (`homeFor`).
* Area clienti alla radice (customer, company; le sezioni anche installer), `/installatori`, `/admin`.
*/
export const routes: Routes = [
{
matcher: authPagesMatcher,
component: AuthLayout,
children: [
{ path: 'login', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login') },
{ path: 'registrazione', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register') },
{ path: 'forgot-password', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/forgot-password') },
{ path: 'reset-password', loadComponent: () => import('./pages/auth/reset-password') },
{ path: 'verify-email', loadComponent: () => import('./pages/auth/verify-email') },
],
},
{
path: '',
component: Shell,
canActivate: [authGuard],
children: [
// Area clienti (prompt 30)
{
path: '',
pathMatch: 'full',
canMatch: [roleGuard('customer', 'company')],
loadComponent: () => import('./pages/customer/dashboard'),
},
{ path: 'ordini', canMatch: customer, loadComponent: () => import('./pages/customer/orders') },
{ path: 'ordini/:numero', canMatch: customer, loadComponent: () => import('./pages/customer/order-detail') },
{ path: 'preventivi', canMatch: customer, loadComponent: () => import('./pages/customer/quotes') },
{ path: 'preventivi/:id', canMatch: customer, loadComponent: () => import('./pages/customer/quote-detail') },
{ path: 'assistenza', canMatch: customer, loadComponent: () => import('./pages/customer/tickets') },
{ path: 'assistenza/nuova', canMatch: customer, loadComponent: () => import('./pages/customer/ticket-new') },
{ path: 'assistenza/:id', canMatch: customer, loadComponent: () => import('./pages/customer/ticket-detail') },
{ path: 'documenti', canMatch: customer, loadComponent: () => import('./pages/customer/documents') },
{ path: 'profilo', canMatch: customer, loadComponent: () => import('./pages/customer/profile') },
// Area installatori (prompt 31)
{
path: 'installatori',
canMatch: [roleGuard('installer')],
children: [
{ path: '', pathMatch: 'full', loadComponent: () => import('./pages/installer/installer-home') },
{ path: 'listino', loadComponent: () => import('./pages/installer/price-list') },
{ path: 'documenti', loadComponent: () => import('./pages/installer/technical-docs') },
{ path: 'profilo', loadComponent: () => import('./pages/installer/installer-profile') },
],
},
// Pannello admin (prompt 32). I `/:id` sono anche i link delle notifiche email allo staff.
{
path: 'admin',
canMatch: [roleGuard('admin')],
children: [
{ path: '', pathMatch: 'full', loadComponent: () => import('./pages/admin/admin-dashboard') },
{ path: 'richieste', loadComponent: () => import('./pages/admin/quote-requests') },
{ path: 'richieste/:id', loadComponent: () => import('./pages/admin/quote-request-detail') },
{ path: 'preventivi', loadComponent: () => import('./pages/admin/quotes') },
{ path: 'preventivi/:id', loadComponent: () => import('./pages/admin/quote-detail') },
{ path: 'ticket', loadComponent: () => import('./pages/admin/tickets') },
{ path: 'ticket/:id', loadComponent: () => import('./pages/admin/ticket-detail') },
{ path: 'installatori', loadComponent: () => import('./pages/admin/installers') },
{ path: 'installatori/:id', loadComponent: () => import('./pages/admin/installer-detail') },
{ path: 'utenti', loadComponent: () => import('./pages/admin/users') },
{ path: 'utenti/:id', loadComponent: () => import('./pages/admin/user-detail') },
{ path: 'blog', loadComponent: () => import('./pages/admin/blog') },
{ path: 'blog/nuovo', loadComponent: () => import('./pages/admin/blog-editor') },
{ path: 'blog/categorie', loadComponent: () => import('./pages/admin/blog-categories') },
{ path: 'blog/:id', loadComponent: () => import('./pages/admin/blog-editor') },
{ path: 'documenti', loadComponent: () => import('./pages/admin/documents') },
{ path: 'contatti', loadComponent: () => import('./pages/admin/contacts') },
{ path: 'contatti/:id', loadComponent: () => import('./pages/admin/contact-detail') },
{ path: 'showroom', loadComponent: () => import('./pages/admin/showroom') },
{ path: 'showroom/:id', loadComponent: () => import('./pages/admin/booking-detail') },
],
},
{ path: '**', loadComponent: () => import('./pages/not-found') },
],
},
]
+33
View File
@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { AuthStore } from './core/auth.store'
/**
* Radice: la sessione viene ritrovata all'avvio (refresh dal cookie). Finché lo stato non è noto si mostra
* un caricamento, così i layout non lampeggiano tra "anonimo" e "autenticato".
*/
@Component({
selector: 'app-root',
imports: [RouterOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (auth.status() === 'unknown') {
<p class="boot" role="status">Caricamento dell'area riservata…</p>
}
<router-outlet />
`,
styles: `
.boot {
padding: var(--space-8) var(--gutter);
text-align: center;
color: var(--color-ink-muted);
}
`,
})
export class App {
protected readonly auth = inject(AuthStore)
constructor() {
void this.auth.ready()
}
}
+103
View File
@@ -0,0 +1,103 @@
import { HttpClient, HttpContext, HttpContextToken, HttpErrorResponse, HttpParams } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { catchError, Observable, throwError } from 'rxjs'
import { APP_CONFIG } from './app-config'
/** Errore dell'API nel formato unico `{ error: { code, message, details? } }` di apps/api. */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details: { path: string; message: string }[] = [],
) {
super(message)
this.name = 'ApiError'
}
}
type QueryValue = string | number | boolean | null | undefined
export type Query = Record<string, QueryValue>
export interface RequestOptions {
query?: Query
/** Invia i cookie (refresh token, carrello): serve solo alle route `/auth/*` e `/shop/*`. */
withCredentials?: boolean
/** Non aggiungere l'access token e non tentare il refresh su 401 (login, registrazione, reset…). */
anonymous?: boolean
}
/**
* Richiesta da NON autenticare (vedi auth.interceptor.ts): le route pubbliche di `/auth/*` e le chiamate
* dell'AuthStore stesso (refresh, login, logout), che altrimenti finirebbero in un ciclo di refresh.
*/
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false)
function httpOptions(options: RequestOptions) {
return {
params: toParams(options.query),
withCredentials: options.withCredentials ?? false,
context: new HttpContext().set(SKIP_AUTH, options.anonymous ?? false),
}
}
function toParams(query: Query | undefined): HttpParams {
let params = new HttpParams()
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value))
}
return params
}
export function toApiError(err: unknown): ApiError {
if (err instanceof ApiError) return err
if (err instanceof HttpErrorResponse) {
const body = err.error as { error?: { code?: string; message?: string; details?: { path: string; message: string }[] } } | null
if (body?.error?.code) {
return new ApiError(err.status, body.error.code, body.error.message ?? 'Errore', body.error.details ?? [])
}
// status 0 = rete, CORS o API non raggiungibile
return err.status === 0
? new ApiError(0, 'NETWORK_ERROR', 'Servizio non raggiungibile: riprova tra poco')
: new ApiError(err.status, 'HTTP_ERROR', 'Si è verificato un errore: riprova tra poco')
}
return new ApiError(0, 'UNKNOWN_ERROR', 'Si è verificato un errore imprevisto')
}
/**
* Client dell'API Fastify. Le richieste sono autenticate da authInterceptor (access token in memoria,
* refresh trasparente), salvo `anonymous: true`. Gli errori arrivano sempre come `ApiError`.
*/
@Injectable({ providedIn: 'root' })
export class ApiService {
private readonly http = inject(HttpClient)
private readonly baseUrl = inject(APP_CONFIG).apiUrl
url(path: string): string {
return `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`
}
get<T>(path: string, options: RequestOptions = {}): Observable<T> {
return this.http.get<T>(this.url(path), httpOptions(options)).pipe(catchError((err: unknown) => throwError(() => toApiError(err))))
}
post<T>(path: string, body: unknown, options: RequestOptions = {}): Observable<T> {
return this.http
.post<T>(this.url(path), body, httpOptions(options))
.pipe(catchError((err: unknown) => throwError(() => toApiError(err))))
}
put<T>(path: string, body: unknown, options: RequestOptions = {}): Observable<T> {
return this.http.put<T>(this.url(path), body, httpOptions(options)).pipe(catchError((err: unknown) => throwError(() => toApiError(err))))
}
patch<T>(path: string, body: unknown, options: RequestOptions = {}): Observable<T> {
return this.http
.patch<T>(this.url(path), body, httpOptions(options))
.pipe(catchError((err: unknown) => throwError(() => toApiError(err))))
}
delete<T>(path: string, options: RequestOptions = {}): Observable<T> {
return this.http.delete<T>(this.url(path), httpOptions(options)).pipe(catchError((err: unknown) => throwError(() => toApiError(err))))
}
}
+60
View File
@@ -0,0 +1,60 @@
import { InjectionToken } from '@angular/core'
import { environment } from '../../environments/environment'
/** Configurazione runtime dell'area riservata. */
export interface AppConfig {
/** URL dell'API Fastify, senza slash finale. */
apiUrl: string
/** URL del sito pubblico, senza slash finale. */
siteUrl: string
/** Dashboard admin di Medusa (in produzione `https://shop-admin.<dominio>/app`), senza slash finale. */
medusaAdminUrl: string
}
export const DEFAULT_CONFIG: AppConfig = environment
/** Configurazione dell'app: fornita da `main.ts` dopo aver letto `/config.json`; nei test valgono i default. */
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG', {
providedIn: 'root',
factory: () => DEFAULT_CONFIG,
})
/** URL http(s) assoluto senza slash finale, oppure null. */
function normalizeUrl(value: unknown): string | null {
if (typeof value !== 'string' || value.trim() === '') return null
try {
const url = new URL(value.trim())
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
return url.href.replace(/\/+$/, '')
} catch {
return null
}
}
/** Fonde i valori letti da `config.json` con i default; i campi assenti o non validi restano ai default. */
export function parseRuntimeConfig(raw: unknown, defaults: AppConfig = DEFAULT_CONFIG): AppConfig {
const data = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const config = { ...defaults }
for (const key of ['apiUrl', 'siteUrl', 'medusaAdminUrl'] as const) {
const value = normalizeUrl(data[key])
if (value) config[key] = value
else if (key in data) console.warn(`config.json: "${key}" non è un URL valido, uso ${defaults[key]}`)
}
return config
}
/**
* Legge `/config.json` (generato in produzione dalle variabili d'ambiente, prompt 33) prima del bootstrap.
* Se manca (sviluppo) valgono i default di environment.ts.
*/
export async function loadRuntimeConfig(): Promise<AppConfig> {
try {
const res = await fetch('config.json', { cache: 'no-store' })
if (res.status === 404) return DEFAULT_CONFIG
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return parseRuntimeConfig(await res.json())
} catch (err) {
console.warn('config.json non leggibile, uso la configurazione di default', err)
return DEFAULT_CONFIG
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { UserRole } from '@iti/shared'
import { DOCUMENT } from '@angular/common'
import { inject } from '@angular/core'
import { type CanActivateFn, type CanMatchFn, Router, type UrlTree } from '@angular/router'
import { APP_CONFIG } from './app-config'
import { AuthStore } from './auth.store'
import { resolvePostLogin } from './redirect'
import { homeFor } from './roles'
function loginTree(router: Router, returnUrl: string | null | undefined): UrlTree {
const redirect = returnUrl && returnUrl !== '/' ? returnUrl : null
return router.createUrlTree(['/login'], { queryParams: { redirect } })
}
/** Route riservate agli utenti autenticati: senza sessione → `/login?redirect=<pagina richiesta>`. */
export const authGuard: CanActivateFn = async (_route, state) => {
const auth = inject(AuthStore)
const router = inject(Router)
await auth.ready()
return auth.isAuthenticated() ? true : loginTree(router, state.url)
}
/**
* Sezione riservata a certi ruoli (`canMatch`: il codice lazy di una sezione non permessa non viene scaricato).
* Senza sessione → login; ruolo non ammesso → pagina iniziale del proprio ruolo.
* Il controllo vero resta nell'API (`requireRole`): questo evita solo di mostrare pagine inutilizzabili.
*/
export function roleGuard(...roles: UserRole[]): CanMatchFn {
return async () => {
const auth = inject(AuthStore)
const router = inject(Router)
await auth.ready()
const role = auth.role()
if (!role) {
const navigation = router.currentNavigation()
return loginTree(router, navigation ? router.serializeUrl(navigation.extractedUrl) : null)
}
return roles.includes(role) ? true : router.parseUrl(homeFor(role))
}
}
/**
* Pagine per utenti non autenticati (login, registrazione, password dimenticata): chi ha già una sessione
* va dove avrebbe portato il login (`?redirect=` validato) o alla pagina iniziale del ruolo.
*/
export const guestGuard: CanActivateFn = async (route) => {
// inject() solo prima dell'await: dopo, il contesto di injection non c'è più
const auth = inject(AuthStore)
const router = inject(Router)
const document = inject(DOCUMENT)
const siteUrl = inject(APP_CONFIG).siteUrl
await auth.ready()
const user = auth.user()
if (!user) return true
const target = resolvePostLogin(route.queryParamMap.get('redirect'), user.role, { app: document.location.origin, site: siteUrl })
if (target.kind === 'external') {
document.location.assign(target.url)
return false
}
return router.parseUrl(target.url)
}
@@ -0,0 +1,44 @@
import { HttpErrorResponse, type HttpInterceptorFn, type HttpRequest } from '@angular/common/http'
import { inject } from '@angular/core'
import { catchError, from, switchMap, throwError } from 'rxjs'
import { SKIP_AUTH } from './api.service'
import { APP_CONFIG } from './app-config'
import { AuthStore } from './auth.store'
/**
* Autentica le richieste verso l'API (mai verso altri host, il token non deve uscire dall'API):
*
* 1. aggiunge `Authorization: Bearer …` con il token in memoria; se è scaduto e la sessione è attiva, prima
* fa un refresh. Le richieste parallele attendono lo **stesso** refresh (AuthStore.refresh è deduplicato);
* 2. su 401 di una richiesta partita col token: un refresh (o il token già rinnovato da un'altra richiesta)
* e **un solo** nuovo tentativo;
* 3. refresh rifiutato → `sessionExpired()`: stato azzerato e login, con ritorno alla pagina corrente.
*
* Escluse le richieste con `SKIP_AUTH` (login, refresh, logout, registrazione, reset…).
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(SKIP_AUTH) || !req.url.startsWith(inject(APP_CONFIG).apiUrl)) return next(req)
const auth = inject(AuthStore)
const withToken = (request: HttpRequest<unknown>, token: string | null) =>
token ? request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : request
return from(auth.validToken()).pipe(
switchMap((token) =>
next(withToken(req, token)).pipe(
catchError((err: unknown) => {
if (!token || !(err instanceof HttpErrorResponse) || err.status !== 401) return throwError(() => err)
return from(auth.renewAfterUnauthorized(token)).pipe(
switchMap((renewed) => {
if (!renewed) {
auth.sessionExpired()
return throwError(() => err)
}
return next(withToken(req, renewed))
}),
)
}),
),
),
)
}
+284
View File
@@ -0,0 +1,284 @@
import type { UserDto, UserRole } from '@iti/shared'
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { provideRouter, Router, withComponentInputBinding } from '@angular/router'
import { RouterTestingHarness } from '@angular/router/testing'
import { firstValueFrom } from 'rxjs'
import { routes } from '../app.routes'
import { registerBody } from '../pages/auth/register'
import { ApiError, ApiService } from './api.service'
import { APP_CONFIG, parseRuntimeConfig } from './app-config'
import { authInterceptor } from './auth.interceptor'
import { AuthStore } from './auth.store'
import { resolvePostLogin } from './redirect'
const API = 'http://api.test'
const SITE = 'http://sito.test'
const APP = 'http://localhost:3000'
const tick = () => new Promise((resolve) => setTimeout(resolve))
const userWith = (role: UserRole): UserDto => ({
id: `u-${role}`,
email: `${role}@example.com`,
role,
firstName: 'Ada',
lastName: 'Rossi',
companyName: null,
vatNumber: null,
emailVerified: true,
createdAt: '2026-01-01T00:00:00.000Z',
})
const setHint = (on: boolean) => {
document.cookie = on ? 'iti_session=1; path=/' : 'iti_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
}
describe('area riservata: sessione, interceptor e guard', () => {
let http: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
provideRouter(routes, withComponentInputBinding()),
{ provide: APP_CONFIG, useValue: { apiUrl: API, siteUrl: SITE, medusaAdminUrl: 'http://localhost:9000/app' } },
],
})
http = TestBed.inject(HttpTestingController)
})
afterEach(() => {
http.verify()
setHint(false)
})
/** Sessione ritrovata col cookie: `expiresIn` sotto il margine di 30 s = token già "scaduto". */
async function restore(role: UserRole = 'customer', expiresIn = 900, token = 'tok-1'): Promise<AuthStore> {
setHint(true)
const auth = TestBed.inject(AuthStore)
const ready = auth.ready()
const req = http.expectOne(`${API}/auth/refresh`)
expect(req.request.withCredentials).toBe(true)
req.flush({ accessToken: token, expiresIn, user: userWith(role) })
await ready
return auth
}
it('senza il cookie indizio: anonimo, nessuna richiesta', async () => {
const auth = TestBed.inject(AuthStore)
await auth.ready()
expect(auth.status()).toBe('anonymous')
http.expectNone(`${API}/auth/refresh`)
})
it('refresh della pagina: sessione ritrovata dal cookie, token solo in memoria', async () => {
const auth = await restore('company')
expect(auth.status()).toBe('authenticated')
expect(auth.role()).toBe('company')
expect(auth.currentToken()).toBe('tok-1')
expect(JSON.stringify({ ...localStorage })).not.toContain('tok-1')
expect(JSON.stringify({ ...sessionStorage })).not.toContain('tok-1')
})
it('refresh rifiutato all’avvio: anonimo', async () => {
setHint(true)
const auth = TestBed.inject(AuthStore)
const ready = auth.ready()
http.expectOne(`${API}/auth/refresh`).flush({ error: { code: 'UNAUTHORIZED', message: 'x' } }, { status: 401, statusText: 'x' })
await ready
expect(auth.status()).toBe('anonymous')
})
it('aggiunge il bearer solo verso l’API', async () => {
await restore()
const api = TestBed.inject(ApiService)
const p = firstValueFrom(api.get('/me'))
await tick()
const req = http.expectOne(`${API}/me`)
expect(req.request.headers.get('Authorization')).toBe('Bearer tok-1')
req.flush({})
await p
const httpClient = TestBed.inject(HttpClient)
const other = firstValueFrom(httpClient.get('https://altro.example.com/x'))
const ext = http.expectOne('https://altro.example.com/x')
expect(ext.request.headers.has('Authorization')).toBe(false)
ext.flush({})
await other
})
it('token scaduto: tre richieste in parallelo, un solo refresh, poi tutte col nuovo token', async () => {
await restore('customer', 10, 'vecchio')
const api = TestBed.inject(ApiService)
const results = Promise.all(['/me/orders', '/me/quotes', '/me/tickets'].map((p) => firstValueFrom(api.get(p))))
await tick()
const refreshes = http.match(`${API}/auth/refresh`)
expect(refreshes).toHaveLength(1)
refreshes[0]!.flush({ accessToken: 'nuovo', expiresIn: 900, user: userWith('customer') })
await tick()
for (const path of ['/me/orders', '/me/quotes', '/me/tickets']) {
const req = http.expectOne(`${API}${path}`)
expect(req.request.headers.get('Authorization')).toBe('Bearer nuovo')
req.flush({ ok: path })
}
expect(await results).toHaveLength(3)
})
it('401 su richieste parallele: un solo refresh e un solo nuovo tentativo ciascuna', async () => {
await restore('customer', 900, 'revocato')
const api = TestBed.inject(ApiService)
const results = Promise.all([firstValueFrom(api.get('/a')), firstValueFrom(api.get('/b'))])
await tick()
for (const path of ['/a', '/b']) {
http.expectOne(`${API}${path}`).flush({ error: { code: 'UNAUTHORIZED', message: 'x' } }, { status: 401, statusText: 'x' })
}
await tick()
const refreshes = http.match(`${API}/auth/refresh`)
expect(refreshes).toHaveLength(1)
refreshes[0]!.flush({ accessToken: 'rinnovato', expiresIn: 900, user: userWith('customer') })
await tick()
for (const path of ['/a', '/b']) {
const retry = http.expectOne(`${API}${path}`)
expect(retry.request.headers.get('Authorization')).toBe('Bearer rinnovato')
retry.flush({ path })
}
expect(await results).toEqual([{ path: '/a' }, { path: '/b' }])
})
it('refresh fallito dopo un 401: logout e login con ritorno alla pagina', async () => {
const auth = await restore()
const router = TestBed.inject(Router)
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true)
const api = TestBed.inject(ApiService)
const p = firstValueFrom(api.get('/me/orders'))
await tick()
http.expectOne(`${API}/me/orders`).flush({ error: { code: 'UNAUTHORIZED', message: 'x' } }, { status: 401, statusText: 'x' })
await tick()
http.expectOne(`${API}/auth/refresh`).flush({ error: { code: 'UNAUTHORIZED', message: 'x' } }, { status: 401, statusText: 'x' })
await expect(p).rejects.toBeInstanceOf(ApiError)
expect(auth.status()).toBe('anonymous')
expect(navigate).toHaveBeenCalledWith(['/login'], expect.objectContaining({ queryParams: expect.objectContaining({ scaduta: 1 }) }))
})
it('login: credenziali con cookie, niente token all’interceptor', async () => {
const auth = TestBed.inject(AuthStore)
const p = auth.login('ada@example.com', 'password-lunga')
const req = http.expectOne(`${API}/auth/login`)
expect(req.request.withCredentials).toBe(true)
expect(req.request.headers.has('Authorization')).toBe(false)
req.flush({ accessToken: 't', expiresIn: 900, user: userWith('installer') })
expect((await p).role).toBe('installer')
expect(auth.area()).toBe('installer')
})
describe('guard per ruolo', () => {
// Le pagine raggiunte caricano i loro dati: qui interessa solo dove porta la navigazione
afterEach(() => http.match(() => true))
it('un customer che apre /admin viene riportato alla sua area', async () => {
await restore('customer')
const harness = await RouterTestingHarness.create()
await harness.navigateByUrl('/admin/utenti')
expect(TestBed.inject(Router).url).toBe('/')
expect(harness.routeNativeElement?.textContent).toContain('Panoramica')
})
it('un admin che apre una pagina clienti va a /admin', async () => {
await restore('admin')
const harness = await RouterTestingHarness.create()
await harness.navigateByUrl('/ordini')
expect(TestBed.inject(Router).url).toBe('/admin')
})
it('un installatore sulla radice va a /installatori, ma vede ordini e preventivi', async () => {
await restore('installer')
const harness = await RouterTestingHarness.create()
await harness.navigateByUrl('/preventivi/123')
expect(TestBed.inject(Router).url).toBe('/preventivi/123')
await harness.navigateByUrl('/')
expect(TestBed.inject(Router).url).toBe('/installatori')
})
it('un customer non entra nell’area installatori', async () => {
await restore('company')
const harness = await RouterTestingHarness.create()
await harness.navigateByUrl('/installatori/listino')
expect(TestBed.inject(Router).url).toBe('/')
})
it('anonimo: login con la pagina richiesta come redirect', async () => {
const harness = await RouterTestingHarness.create()
await harness.navigateByUrl('/admin/blog')
expect(TestBed.inject(Router).url).toBe('/login?redirect=%2Fadmin%2Fblog')
await harness.navigateByUrl('/ordini')
expect(TestBed.inject(Router).url).toBe('/login?redirect=%2Fordini')
})
it('utente già autenticato sul login: va alla sua area', async () => {
await restore('admin')
const harness = await RouterTestingHarness.create()
await harness.navigateByUrl('/login')
expect(TestBed.inject(Router).url).toBe('/admin')
})
})
})
describe('redirect dopo il login', () => {
const origins = { app: APP, site: SITE }
it('percorsi interni e URL dell’app', () => {
expect(resolvePostLogin('/preventivi/1?x=2', 'customer', origins)).toEqual({ kind: 'internal', url: '/preventivi/1?x=2' })
expect(resolvePostLogin(`${APP}/ordini`, 'customer', origins)).toEqual({ kind: 'internal', url: '/ordini' })
})
it('pagine del sito pubblico (checkout)', () => {
expect(resolvePostLogin(`${SITE}/checkout`, 'customer', origins)).toEqual({ kind: 'external', url: `${SITE}/checkout` })
})
it('open redirect e valori strani → pagina iniziale del ruolo', () => {
for (const bad of ['https://evil.example.com/', '//evil.example.com', '/\\evil.example.com', 'javascript:alert(1)', 'ordini', '']) {
expect(resolvePostLogin(bad, 'installer', origins)).toEqual({ kind: 'internal', url: '/installatori' })
}
expect(resolvePostLogin(null, 'admin', origins)).toEqual({ kind: 'internal', url: '/admin' })
expect(resolvePostLogin('/login?redirect=/x', 'customer', origins)).toEqual({ kind: 'internal', url: '/' })
})
})
describe('configurazione e registrazione', () => {
it('config.json: URL validi senza slash finale, il resto ai default', () => {
const defaults = { apiUrl: 'http://localhost:3000', siteUrl: 'http://localhost:4200', medusaAdminUrl: 'http://localhost:9000/app' }
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
expect(parseRuntimeConfig({ apiUrl: 'https://api.iti.it/', siteUrl: 'ftp://x' }, defaults)).toEqual({
apiUrl: 'https://api.iti.it',
siteUrl: 'http://localhost:4200',
medusaAdminUrl: 'http://localhost:9000/app',
})
expect(parseRuntimeConfig(null, defaults)).toEqual(defaults)
})
it('corpo della registrazione: campi aziendali solo se servono, niente stringhe vuote', () => {
const base = {
firstName: ' Ada ',
lastName: 'Rossi',
companyName: 'Rossi Srl',
vatNumber: '',
phone: '',
email: ' ada@example.com ',
password: 'password-lunga',
acceptMarketing: false,
}
expect(registerBody({ ...base, accountType: 'customer' })).toMatchObject({
accountType: 'customer',
firstName: 'Ada',
email: 'ada@example.com',
companyName: undefined,
vatNumber: undefined,
phone: undefined,
acceptPrivacy: true,
})
expect(registerBody({ ...base, accountType: 'installer' })).toMatchObject({ companyName: 'Rossi Srl', vatNumber: undefined })
})
})
+233
View File
@@ -0,0 +1,233 @@
import type { UserDto } from '@iti/shared'
import { DOCUMENT } from '@angular/common'
import { HttpClient, HttpContext } from '@angular/common/http'
import { computed, inject, Injectable, signal } from '@angular/core'
import { Router } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { SKIP_AUTH, toApiError } from './api.service'
import { APP_CONFIG } from './app-config'
import { resolvePostLogin } from './redirect'
import { areaOf } from './roles'
/** Cookie "indizio" impostato dall'API insieme al refresh token (non httpOnly, valore "1", nessun segreto). */
export const SESSION_HINT_COOKIE = 'iti_session'
/** Margine prima della scadenza oltre il quale l'access token si considera già scaduto. */
const EXPIRY_MARGIN_MS = 30_000
/** Nome del Web Lock che serializza i refresh tra le schede: lo stesso dell'app pubblica (apps/public). */
export const REFRESH_LOCK = 'iti-auth-refresh'
export interface SessionResponse {
accessToken: string
expiresIn: number
user: UserDto
}
export type AuthStatus = 'unknown' | 'anonymous' | 'authenticated'
/**
* Sessione dell'area riservata.
*
* - **Access token solo in memoria** (mai localStorage/sessionStorage): un refresh della pagina lo perde e la
* sessione si ritrova con `POST /auth/refresh`, che usa il cookie httpOnly `iti_rt`.
* - All'avvio il refresh parte solo se c'è il cookie indizio `iti_session` (impostato e cancellato dall'API
* insieme a `iti_rt`): senza, l'utente è anonimo e non parte nessuna richiesta destinata a un 401.
* - **Un solo refresh alla volta**: le richieste concorrenti condividono la stessa promise, e tra le schede i
* refresh sono serializzati da un Web Lock. Ogni refresh ruota il token e due refresh paralleli con lo
* stesso cookie verrebbero visti dall'API come riuso (sessione revocata).
*/
@Injectable({ providedIn: 'root' })
export class AuthStore {
private readonly http = inject(HttpClient)
private readonly router = inject(Router)
private readonly document = inject(DOCUMENT)
private readonly config = inject(APP_CONFIG)
private readonly _status = signal<AuthStatus>('unknown')
private readonly _user = signal<UserDto | null>(null)
readonly status = this._status.asReadonly()
readonly user = this._user.asReadonly()
readonly role = computed(() => this._user()?.role ?? null)
readonly area = computed(() => {
const role = this.role()
return role ? areaOf(role) : null
})
readonly isAuthenticated = computed(() => this._status() === 'authenticated')
private token: { value: string; expiresAt: number } | null = null
private initialized: Promise<void> | null = null
private refreshing: Promise<boolean> | null = null
private redirectingToLogin = false
/** Si risolve quando lo stato iniziale della sessione è noto. */
ready(): Promise<void> {
this.initialized ??= this.init()
return this.initialized
}
/** Access token valido in questo momento, senza attese (null se assente o in scadenza). */
currentToken(): string | null {
return this.token && this.token.expiresAt - EXPIRY_MARGIN_MS > Date.now() ? this.token.value : null
}
/**
* Token da usare per una richiesta: quello in memoria se valido, altrimenti (sessione attiva ma token
* scaduto) dopo un refresh condiviso. Null per un utente anonimo.
*/
async validToken(): Promise<string | null> {
if (this._status() === 'unknown') await this.ready()
const token = this.currentToken()
if (token || this._status() !== 'authenticated') return token
return (await this.refresh()) ? this.currentToken() : null
}
/**
* Nuovo token dopo un 401 ottenuto con `rejected`. Se nel frattempo un'altra richiesta ha già rinnovato il
* token, si usa quello senza un altro refresh. Null se la sessione non c'è più.
*/
async renewAfterUnauthorized(rejected: string): Promise<string | null> {
const current = this.currentToken()
if (current && current !== rejected) return current
return (await this.refresh()) ? this.currentToken() : null
}
/** Nuovo access token dal cookie di refresh (deduplicato); false se la sessione non c'è più. */
refresh(): Promise<boolean> {
this.refreshing ??= this.withLock(() => this.doRefresh()).finally(() => (this.refreshing = null))
return this.refreshing
}
async login(email: string, password: string): Promise<UserDto> {
try {
const res = await firstValueFrom(
this.http.post<SessionResponse>(this.url('/auth/login'), { email, password }, this.authOptions()),
)
this.setSession(res)
return res.user
} catch (err) {
throw toApiError(err)
}
}
/** Chiude la sessione su questo dispositivo e torna al login. Lo stato locale si azzera anche con l'API giù. */
async logout(): Promise<void> {
try {
await firstValueFrom(this.http.post(this.url('/auth/logout'), null, this.authOptions()))
} catch {
// il cookie resta, ma senza access token l'utente è comunque uscito da quest'app
}
this.clear()
await this.router.navigateByUrl('/login')
}
/** Utente aggiornato da una risposta dell'API (es. `PATCH /me`). */
setUser(user: UserDto): void {
if (this.isAuthenticated()) this._user.set(user)
}
/**
* Nuova sessione restituita dall'API senza passare dal login (cambio password: le sessioni precedenti sono
* state chiuse e il cookie di refresh sostituito).
*/
applySession(res: SessionResponse): void {
this.setSession(res)
}
/** Dimentica la sessione locale senza chiamare l'API (es. account appena disattivato). */
forget(): void {
this.clear()
}
/** Rilegge l'utente da `GET /me` (es. dopo la verifica dell'email). */
async reloadUser(): Promise<void> {
if (!this.isAuthenticated()) return
const token = await this.validToken()
if (!token) return
const user = await firstValueFrom(
this.http.get<UserDto>(this.url('/me'), { headers: { Authorization: `Bearer ${token}` }, context: this.skipAuth() }),
)
this._user.set(user)
}
/**
* Sessione persa durante l'uso (refresh rifiutato): stato azzerato e login, con ritorno alla pagina corrente.
* Chiamato dall'interceptor: più richieste fallite insieme producono una sola navigazione.
*/
sessionExpired(): void {
this.clear()
const current = this.router.url
if (this.redirectingToLogin || current.startsWith('/login')) return
this.redirectingToLogin = true
void this.router
.navigate(['/login'], { queryParams: { redirect: current && current !== '/' ? current : null, scaduta: 1 } })
.finally(() => (this.redirectingToLogin = false))
}
/** Naviga alla destinazione del `?redirect=` validato, oppure alla pagina iniziale del ruolo. */
async navigateAfterLogin(redirect: string | null): Promise<void> {
const user = this._user()
if (!user) return
const target = resolvePostLogin(redirect, user.role, { app: this.document.location.origin, site: this.config.siteUrl })
if (target.kind === 'external') {
this.document.location.assign(target.url)
return
}
await this.router.navigateByUrl(target.url, { replaceUrl: true })
}
private async init(): Promise<void> {
if (!this.hasHintCookie()) {
this._status.set('anonymous')
return
}
await this.refresh()
}
private async doRefresh(): Promise<boolean> {
try {
const res = await firstValueFrom(this.http.post<SessionResponse>(this.url('/auth/refresh'), null, this.authOptions()))
this.setSession(res)
return true
} catch {
// 401 (sessione scaduta o revocata: l'API cancella anche i cookie), rete, rate limit
this.clear()
return false
}
}
private setSession(res: SessionResponse): void {
this.token = { value: res.accessToken, expiresAt: Date.now() + res.expiresIn * 1000 }
this._user.set(res.user)
this._status.set('authenticated')
}
private clear(): void {
this.token = null
this._user.set(null)
this._status.set('anonymous')
}
private url(path: string): string {
return `${this.config.apiUrl}${path}`
}
private skipAuth(): HttpContext {
return new HttpContext().set(SKIP_AUTH, true)
}
/** Cookie inviati e ricevuti (refresh token, carrello) e nessun intervento dell'interceptor. */
private authOptions() {
return { withCredentials: true, context: this.skipAuth() }
}
private hasHintCookie(): boolean {
return this.document.cookie.split(';').some((c) => c.trim() === `${SESSION_HINT_COOKIE}=1`)
}
/** Esegue `fn` sotto il Web Lock condiviso tra le schede, se il browser lo supporta. */
private withLock<T>(fn: () => Promise<T>): Promise<T> {
const locks = (globalThis.navigator as Navigator | undefined)?.locks
return locks ? locks.request(REFRESH_LOCK, fn) : fn()
}
}
@@ -0,0 +1,73 @@
import { DOCUMENT } from '@angular/common'
import { HttpClient } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { firstValueFrom } from 'rxjs'
import { ApiError, ApiService, toApiError } from './api.service'
const EXTENSIONS: Record<string, string> = {
'application/pdf': '.pdf',
'application/zip': '.zip',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
'image/vnd.dwg': '.dwg',
'image/vnd.dxf': '.dxf',
}
/** Nome di file sicuro da un titolo ("Scheda: pompa 8 kW" → "Scheda pompa 8 kW.pdf"). */
export function fileName(title: string, mimeType: string): string {
const base = title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || 'documento'
const ext = EXTENSIONS[mimeType] ?? ''
return base.toLowerCase().endsWith(ext) ? base : `${base}${ext}`
}
/**
* Download dei file protetti (`/documents/:id/download`, `/me/quotes/:id/pdf`). Un semplice link non
* porterebbe l'access token (che sta solo in memoria): il file si scarica con HttpClient (interceptor →
* Authorization), poi si salva con un link a un object URL.
*/
@Injectable({ providedIn: 'root' })
export class DownloadService {
private readonly http = inject(HttpClient)
private readonly api = inject(ApiService)
private readonly document = inject(DOCUMENT)
async download(path: string, name: string): Promise<void> {
let blob: Blob
try {
blob = await firstValueFrom(this.http.get(this.api.url(path), { responseType: 'blob' }))
} catch (err) {
throw await this.blobError(err)
}
const url = URL.createObjectURL(blob)
try {
const link = this.document.createElement('a')
link.href = url
link.download = name
link.rel = 'noopener'
this.document.body.append(link)
link.click()
link.remove()
} finally {
// il browser ha già avviato il salvataggio: l'URL si può liberare al prossimo giro
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
}
/** Con `responseType: 'blob'` anche il corpo dell'errore è un Blob: lo si rilegge come JSON. */
private async blobError(err: unknown): Promise<ApiError> {
const body = (err as { error?: unknown }).error
if (body instanceof Blob) {
try {
const parsed = JSON.parse(await body.text()) as { error?: { code?: string; message?: string } }
if (parsed.error?.code) {
return new ApiError((err as { status?: number }).status ?? 0, parsed.error.code, parsed.error.message ?? 'Errore')
}
} catch {
// corpo non JSON: errore generico sotto
}
}
return toApiError(err)
}
}
+50
View File
@@ -0,0 +1,50 @@
/** Formattazione per l'italiano. */
const euroOptions: Intl.NumberFormatOptions = {
style: 'currency',
currency: 'EUR',
// Il CLDR it-IT non separa le migliaia sotto le 5 cifre ("1190 €"): lo forziamo ("1.190 €")
useGrouping: 'always',
}
const euroWhole = new Intl.NumberFormat('it-IT', { ...euroOptions, maximumFractionDigits: 0 })
const euroCents = new Intl.NumberFormat('it-IT', { ...euroOptions, minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** Centesimi (come nei DTO dell'API) → "1.190 €" se l'importo è intero, altrimenti "992,70 €". */
export function formatEuro(cents: number): string {
return cents % 100 === 0 ? euroWhole.format(cents / 100) : euroCents.format(cents / 100)
}
const longDate = new Intl.DateTimeFormat('it-IT', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome' })
/** Data ISO → "3 ottobre 2026". */
export function formatDate(iso: string): string {
return longDate.format(new Date(iso))
}
const decimal = new Intl.NumberFormat('it-IT', { maximumFractionDigits: 1 })
/** Dimensione di un file → "850 kB", "2,4 MB". */
export function formatBytes(bytes: number): string {
if (bytes < 1000) return `${bytes} B`
if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} kB`
return `${decimal.format(bytes / 1_000_000)} MB`
}
const dateTime = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Rome',
})
/** Data e ora ISO → "3 ott 2026, 18:40" (fuso Europe/Rome). */
export function formatDateTime(iso: string): string {
return dateTime.format(new Date(iso))
}
/** Data senza ora (YYYY-MM-DD, es. `validUntil` dei preventivi) → "31 ottobre 2026", senza slittamenti di fuso. */
export function formatDay(ymd: string): string {
return longDate.format(new Date(`${ymd}T12:00:00Z`))
}
@@ -0,0 +1,71 @@
import type { InstallerProfileDto, InstallerStatus } from '@iti/shared'
import { computed, inject, Injectable } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { catchError, firstValueFrom, of, throwError } from 'rxjs'
import { ApiError, ApiService } from './api.service'
import { AuthStore } from './auth.store'
/** Corpo di `PUT /installer/profile`. */
export interface InstallerProfileInput {
businessName: string
vatNumber: string | null
phone: string | null
address: string | null
location: { lat: number; lng: number } | null
serviceRadiusKm: number
certifications: string[]
}
/** Stato dell'installatore per l'interfaccia: `missing` = profilo non ancora creato. */
export type InstallerState = InstallerStatus | 'missing'
/** Dati del profilo che mancano per una valutazione completa (in ordine di importanza). */
export function missingProfileFields(p: InstallerProfileDto | null): string[] {
if (!p) return ['ragione sociale', 'partita IVA', 'telefono', 'indirizzo della sede', 'posizione sulla mappa']
const missing: string[] = []
if (!p.vatNumber) missing.push('partita IVA')
if (!p.phone) missing.push('telefono')
if (!p.address) missing.push('indirizzo della sede')
if (!p.location) missing.push('posizione sulla mappa')
if (p.certifications.length === 0) missing.push('certificazioni')
return missing
}
/**
* Profilo dell'installatore autenticato (`GET /installer/profile`), condiviso tra home, profilo, listino e
* documentazione: lo stato (`pending`, `approved`, `rejected`) decide cosa mostrare. Il controllo vero resta
* nell'API, che dà listino e documenti tecnici solo agli approvati. 404 = profilo non ancora creato.
*/
@Injectable({ providedIn: 'root' })
export class InstallerProfileStore {
private readonly api = inject(ApiService)
private readonly auth = inject(AuthStore)
readonly resource = rxResource({
params: () => {
const user = this.auth.user()
return user?.role === 'installer' ? user.id : undefined
},
stream: () =>
this.api
.get<InstallerProfileDto>('/installer/profile')
.pipe(catchError((err: unknown) => (err instanceof ApiError && err.status === 404 ? of(null) : throwError(() => err)))),
})
readonly profile = computed(() => (this.resource.hasValue() ? this.resource.value() : undefined))
readonly state = computed<InstallerState | null>(() => {
const p = this.profile()
return p === undefined ? null : p === null ? 'missing' : p.status
})
readonly approved = computed(() => this.state() === 'approved')
readonly missing = computed(() => {
const p = this.profile()
return p === undefined ? [] : missingProfileFields(p)
})
async save(input: InstallerProfileInput): Promise<InstallerProfileDto> {
const saved = await firstValueFrom(this.api.put<InstallerProfileDto>('/installer/profile', input))
this.resource.set(saved)
return saved
}
}
+152
View File
@@ -0,0 +1,152 @@
import type {
BlogPostStatus,
ContactRequestStatus,
DocumentVisibility,
InstallerStatus,
MyOrderDto,
QuoteRequestStatus,
QuoteStatus,
ShowroomBookingStatus,
TicketPriority,
TicketStatus,
} from '@iti/shared'
/** Tono del badge di stato (colore): neutro, in corso, positivo, da fare, negativo. */
export type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
export interface StatusLabel {
label: string
tone: Tone
}
/**
* Stato di un ordine in una sola etichetta per il cliente, dagli stati Medusa (ordine, pagamento, spedizione).
* Priorità: annullato > consegnato > spedito > preparato > pagamento.
*/
export function orderStatus(order: Pick<MyOrderDto, 'status' | 'paymentStatus' | 'fulfillmentStatus'>): StatusLabel {
if (order.status === 'canceled') return { label: 'Annullato', tone: 'danger' }
switch (order.fulfillmentStatus) {
case 'delivered':
return { label: 'Consegnato', tone: 'success' }
case 'partially_delivered':
return { label: 'Consegnato in parte', tone: 'info' }
case 'shipped':
return { label: 'Spedito', tone: 'info' }
case 'partially_shipped':
return { label: 'Spedito in parte', tone: 'info' }
case 'fulfilled':
case 'partially_fulfilled':
return { label: 'In preparazione', tone: 'info' }
}
if (order.status === 'completed') return { label: 'Completato', tone: 'success' }
switch (order.paymentStatus) {
case 'captured':
case 'authorized':
case 'partially_captured':
case 'partially_authorized':
return { label: 'Confermato', tone: 'info' }
case 'refunded':
return { label: 'Rimborsato', tone: 'neutral' }
case 'partially_refunded':
return { label: 'Rimborsato in parte', tone: 'neutral' }
case 'requires_action':
return { label: 'Pagamento da completare', tone: 'warning' }
default:
return { label: 'In attesa di pagamento', tone: 'warning' }
}
}
export const QUOTE_STATUS: Record<QuoteStatus, StatusLabel> = {
draft: { label: 'In preparazione', tone: 'neutral' },
sent: { label: 'Da valutare', tone: 'warning' },
accepted: { label: 'Accettato', tone: 'success' },
rejected: { label: 'Rifiutato', tone: 'neutral' },
expired: { label: 'Scaduto', tone: 'danger' },
}
export const TICKET_STATUS: Record<TicketStatus, StatusLabel> = {
open: { label: 'Aperto', tone: 'info' },
in_progress: { label: 'In lavorazione', tone: 'info' },
waiting_customer: { label: 'In attesa di tua risposta', tone: 'warning' },
closed: { label: 'Chiuso', tone: 'neutral' },
}
export const TICKET_PRIORITY: Record<TicketPriority, string> = {
low: 'Bassa',
normal: 'Normale',
high: 'Alta',
urgent: 'Urgente',
}
/** Preventivo a cui il cliente può ancora rispondere (l'API mostra già come `expired` quelli scaduti). */
export const isQuotePending = (status: QuoteStatus) => status === 'sent'
/** Ticket non chiuso (in dashboard tra quelli aperti). */
export const isTicketOpen = (status: TicketStatus) => status !== 'closed'
// --- Pannello admin ---
/** Stato dei ticket visto dallo staff ("in attesa del cliente" invece di "in attesa di tua risposta"). */
export const ADMIN_TICKET_STATUS: Record<TicketStatus, StatusLabel> = {
open: { label: 'Da gestire', tone: 'warning' },
in_progress: { label: 'In lavorazione', tone: 'info' },
waiting_customer: { label: 'In attesa del cliente', tone: 'neutral' },
closed: { label: 'Chiuso', tone: 'neutral' },
}
/** Stato dei preventivi visto dallo staff. */
export const ADMIN_QUOTE_STATUS: Record<QuoteStatus, StatusLabel> = {
draft: { label: 'Bozza', tone: 'neutral' },
sent: { label: 'Inviato', tone: 'info' },
accepted: { label: 'Accettato', tone: 'success' },
rejected: { label: 'Rifiutato', tone: 'danger' },
expired: { label: 'Scaduto', tone: 'warning' },
}
export const QUOTE_REQUEST_STATUS: Record<QuoteRequestStatus, StatusLabel> = {
new: { label: 'Nuova', tone: 'warning' },
contacted: { label: 'Contattato', tone: 'info' },
converted: { label: 'Preventivo inviato', tone: 'success' },
closed: { label: 'Chiusa', tone: 'neutral' },
}
export const INSTALLER_STATUS: Record<InstallerStatus, StatusLabel> = {
pending: { label: 'In attesa', tone: 'warning' },
approved: { label: 'Approvato', tone: 'success' },
rejected: { label: 'Rifiutato', tone: 'danger' },
}
export const CONTACT_STATUS: Record<ContactRequestStatus, StatusLabel> = {
new: { label: 'Nuova', tone: 'warning' },
in_progress: { label: 'In gestione', tone: 'info' },
closed: { label: 'Chiusa', tone: 'neutral' },
}
export const BOOKING_STATUS: Record<ShowroomBookingStatus, StatusLabel> = {
requested: { label: 'Da confermare', tone: 'warning' },
confirmed: { label: 'Confermata', tone: 'info' },
cancelled: { label: 'Annullata', tone: 'neutral' },
completed: { label: 'Completata', tone: 'success' },
}
export const BLOG_STATUS: Record<BlogPostStatus, StatusLabel> = {
draft: { label: 'Bozza', tone: 'neutral' },
published: { label: 'Pubblicato', tone: 'success' },
}
/** Chi può vedere un documento. */
export const VISIBILITY_LABELS: Record<DocumentVisibility, string> = {
public: 'Tutti (anche senza account)',
customer: 'Clienti registrati',
installer: 'Installatori approvati',
admin: 'Solo staff',
}
/** Utente attivo o disabilitato. */
export const accountStatus = (disabledAt: string | null): StatusLabel =>
disabledAt ? { label: 'Disabilitato', tone: 'danger' } : { label: 'Attivo', tone: 'success' }
/** Opzioni di un `<select>` da una mappa di etichette. */
export function optionsOf<K extends string>(labels: Record<K, StatusLabel | string>): { value: K; label: string }[] {
return (Object.entries(labels) as [K, StatusLabel | string][]).map(([value, l]) => ({ value, label: typeof l === 'string' ? l : l.label }))
}
+48
View File
@@ -0,0 +1,48 @@
import type { UserRole } from '@iti/shared'
import { homeFor } from './roles'
/** Dove andare dopo il login: una route di quest'app o una pagina del sito pubblico (es. il checkout). */
export type PostLoginTarget = { kind: 'internal'; url: string } | { kind: 'external'; url: string }
/** Pagine per utenti non autenticati: dopo il login non ha senso tornarci. */
const GUEST_PATHS = ['/login', '/registrazione', '/forgot-password', '/reset-password']
const ABSOLUTE = /^https?:\/\//i
/**
* Valida il parametro `?redirect=` del login, contro gli open redirect:
* - percorso di quest'app (`/preventivi/…`; non `//host` né `/\host`, che il browser tratta come un altro host);
* - URL assoluto di quest'app (stessa origine) → percorso interno;
* - URL assoluto del sito pubblico (stessa origine di `siteUrl`) → navigazione esterna (contratto col checkout).
* Tutto il resto, o niente, porta alla pagina iniziale del ruolo.
*/
export function resolvePostLogin(
raw: string | null | undefined,
role: UserRole,
origins: { app: string; site: string },
): PostLoginTarget {
const home: PostLoginTarget = { kind: 'internal', url: homeFor(role) }
const value = raw?.trim()
if (!value) return home
const isPath = value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\')
if (!isPath && !ABSOLUTE.test(value)) return home
let url: URL
let appOrigin: string
let siteOrigin: string
try {
appOrigin = new URL(origins.app).origin
siteOrigin = new URL(origins.site).origin
url = new URL(value, appOrigin)
} catch {
return home
}
if (isPath || url.origin === appOrigin) {
if (GUEST_PATHS.some((p) => url.pathname === p || url.pathname.startsWith(`${p}/`))) return home
return { kind: 'internal', url: `${url.pathname}${url.search}${url.hash}` }
}
if (url.origin === siteOrigin) return { kind: 'external', url: url.href }
return home
}
+69
View File
@@ -0,0 +1,69 @@
import type { UserRole } from '@iti/shared'
/** Area dell'app: determina menu e pagina iniziale. `customer` e `company` condividono l'area clienti. */
export type Area = 'customer' | 'installer' | 'admin'
export function areaOf(role: UserRole): Area {
return role === 'admin' ? 'admin' : role === 'installer' ? 'installer' : 'customer'
}
/** Pagina iniziale di ogni ruolo (redirect dopo il login e quando si apre una sezione non permessa). */
export function homeFor(role: UserRole): string {
switch (areaOf(role)) {
case 'admin':
return '/admin'
case 'installer':
return '/installatori'
default:
return '/'
}
}
export const ROLE_LABELS: Record<UserRole, string> = {
customer: 'Cliente',
company: 'Azienda',
installer: 'Installatore',
admin: 'Amministratore',
}
/** Ruoli che usano l'area clienti (ordini, preventivi, assistenza, documenti, account). */
export const CUSTOMER_AREA_ROLES: readonly UserRole[] = ['customer', 'company', 'installer']
export interface NavItem {
label: string
path: string
/** Attivo solo sul percorso esatto (pagine iniziali, che sono prefisso delle altre voci). */
exact?: boolean
}
const CUSTOMER_ITEMS: NavItem[] = [
{ label: 'Ordini', path: '/ordini' },
{ label: 'Preventivi', path: '/preventivi' },
{ label: 'Assistenza', path: '/assistenza' },
{ label: 'Documenti', path: '/documenti' },
{ label: 'Il mio account', path: '/profilo' },
]
/** Menu laterale per area. All'area admin lo shell aggiunge il link esterno al dashboard Medusa (catalogo e ordini). */
export const MENUS: Record<Area, NavItem[]> = {
customer: [{ label: 'Panoramica', path: '/', exact: true }, ...CUSTOMER_ITEMS],
installer: [
{ label: 'Area installatore', path: '/installatori', exact: true },
{ label: 'Listino riservato', path: '/installatori/listino' },
{ label: 'Documentazione tecnica', path: '/installatori/documenti' },
{ label: 'Profilo aziendale', path: '/installatori/profilo' },
...CUSTOMER_ITEMS.map((item) => (item.path === '/documenti' ? { ...item, label: 'Altri documenti' } : item)),
],
admin: [
{ label: 'Dashboard', path: '/admin', exact: true },
{ label: 'Richieste preventivo', path: '/admin/richieste' },
{ label: 'Preventivi', path: '/admin/preventivi' },
{ label: 'Ticket', path: '/admin/ticket' },
{ label: 'Installatori', path: '/admin/installatori' },
{ label: 'Utenti', path: '/admin/utenti' },
{ label: 'Blog', path: '/admin/blog' },
{ label: 'Documenti', path: '/admin/documenti' },
{ label: 'Contatti', path: '/admin/contatti' },
{ label: 'Showroom', path: '/admin/showroom' },
],
}
@@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink, RouterOutlet } from '@angular/router'
import { APP_CONFIG } from '../core/app-config'
/** Layout delle pagine di accesso (login, registrazione, password, verifica email): logo, riquadro centrale. */
@Component({
selector: 'app-auth-layout',
imports: [RouterOutlet, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header class="top">
<a routerLink="/login" class="brand">
<img src="logo.svg" alt="" width="40" height="40" />
<span><strong>ITI TECHNOLOGY</strong> <span class="sub">Area riservata</span></span>
</a>
<a class="back" [href]="siteUrl">← Torna al sito</a>
</header>
<main id="contenuto" class="card-wrap">
<div class="panel card">
<router-outlet />
</div>
</main>
`,
styles: `
:host {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--gutter);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--color-ink);
text-decoration: none;
img {
width: 40px;
height: 40px;
}
}
.sub {
color: var(--color-ink-muted);
font-size: var(--font-size-sm);
}
.back {
display: inline-flex;
align-items: center;
min-height: 44px;
}
.card-wrap {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-start;
padding: var(--space-5) var(--gutter) var(--space-8);
}
.card {
width: 100%;
max-width: 560px;
}
`,
})
export class AuthLayout {
protected readonly siteUrl = inject(APP_CONFIG).siteUrl
}
+79
View File
@@ -0,0 +1,79 @@
<a class="skip-link" href="#contenuto">Vai al contenuto</a>
<header class="top">
<div class="top__inner">
<a routerLink="/" class="brand">
<img src="logo.svg" alt="" width="36" height="36" />
<span><strong>ITI TECHNOLOGY</strong> <span class="sub">Area riservata</span></span>
</a>
<button
type="button"
class="menu-toggle"
aria-controls="menu-area"
[attr.aria-expanded]="menuOpen()"
(click)="toggleMenu()"
>
{{ menuOpen() ? 'Chiudi menu' : 'Menu' }}
</button>
<div class="account">
@if (auth.user(); as user) {
<span class="who">
<strong>{{ user.firstName }} {{ user.lastName }}</strong>
<span class="role">{{ roleLabel() }}</span>
</span>
}
<a class="link" [href]="siteUrl">Torna al sito</a>
<button type="button" class="btn btn--outline" (click)="logout()">Esci</button>
</div>
</div>
</header>
<div class="layout">
<nav id="menu-area" class="nav" [class.nav--open]="menuOpen()" aria-label="Area riservata">
<ul>
@for (item of menu(); track item.path) {
<li>
<a
[routerLink]="item.path"
routerLinkActive="active"
ariaCurrentWhenActive="page"
[routerLinkActiveOptions]="{ exact: item.exact ?? false }"
>{{ item.label }}</a
>
</li>
}
@if (auth.area() === 'admin') {
<li>
<a [href]="medusaAdminUrl" target="_blank" rel="noopener"><span>Catalogo e ordini <span aria-hidden="true">↗</span></span><span class="visually-hidden"> (dashboard Medusa, si apre in una nuova scheda)</span></a>
</li>
}
</ul>
</nav>
<main id="contenuto" tabindex="-1" class="content">
@if (auth.user()?.emailVerified === false) {
<div class="alert verify" role="status">
<p>
<strong>Conferma il tuo indirizzo email.</strong> Ti abbiamo inviato un link a {{ auth.user()?.email }}: finché
non lo apri alcune funzioni restano limitate (es. il collegamento degli ordini fatti come ospite).
</p>
@switch (resend()) {
@case ('sent') {
<p>Fatto: se l'email non è ancora confermata riceverai un nuovo link.</p>
}
@case ('error') {
<p class="field-error">{{ resendError() }}</p>
}
@default {
<button type="button" class="link-button" [disabled]="resend() === 'sending'" (click)="resendVerification()">
Invia di nuovo il link
</button>
}
}
</div>
}
<router-outlet />
</main>
</div>
+186
View File
@@ -0,0 +1,186 @@
@use 'mixins' as mq;
:host {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.skip-link {
position: absolute;
left: var(--space-4);
top: -100px;
z-index: 100;
padding: var(--space-2) var(--space-4);
background: var(--color-primary);
color: var(--color-on-primary);
border-radius: var(--radius-md);
&:focus {
top: var(--space-2);
}
}
.top {
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid var(--color-line);
background: var(--color-surface);
}
.top__inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--gutter);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
margin-right: auto;
color: var(--color-ink);
text-decoration: none;
img {
width: 36px;
height: 36px;
}
}
.sub {
color: var(--color-ink-muted);
font-size: var(--font-size-sm);
}
.menu-toggle {
min-height: 44px;
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-ink);
font-weight: var(--font-weight-semibold);
cursor: pointer;
@include mq.up(lg) {
display: none;
}
}
.account {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
width: 100%;
@include mq.up(md) {
width: auto;
}
.btn {
min-height: 40px;
padding-block: var(--space-1);
}
}
.who {
display: flex;
flex-direction: column;
line-height: var(--line-height-snug);
}
.role {
font-size: var(--font-size-xs);
color: var(--color-ink-muted);
}
.link {
display: inline-flex;
align-items: center;
min-height: 44px;
}
.layout {
flex: 1;
display: grid;
grid-template-columns: 1fr;
@include mq.up(lg) {
grid-template-columns: 240px 1fr;
}
}
.nav {
display: none;
border-bottom: 1px solid var(--color-line);
background: var(--color-surface);
&--open {
display: block;
}
@include mq.up(lg) {
display: block;
border-bottom: 0;
border-right: 1px solid var(--color-line);
}
ul {
margin: 0;
padding: var(--space-3);
list-style: none;
@include mq.up(lg) {
position: sticky;
top: 72px;
}
}
a {
display: flex;
align-items: center;
min-height: 44px;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
color: var(--color-ink);
text-decoration: none;
&:hover {
background: var(--color-surface-alt);
}
&.active {
background: var(--color-primary-soft);
color: var(--color-primary-strong);
font-weight: var(--font-weight-semibold);
}
}
}
.content {
min-width: 0;
padding: var(--space-5) var(--gutter) var(--space-8);
outline: none;
@include mq.up(lg) {
padding-inline: var(--space-6);
}
}
.verify {
color: var(--color-warning);
background: var(--color-accent-soft);
p {
color: var(--color-ink);
}
p:last-child {
margin-bottom: 0;
}
}
+67
View File
@@ -0,0 +1,67 @@
import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'
import { filter, firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../core/api.service'
import { APP_CONFIG } from '../core/app-config'
import { AuthStore } from '../core/auth.store'
import { MENUS, ROLE_LABELS } from '../core/roles'
/**
* Layout delle pagine autenticate: intestazione (utente, "Torna al sito", "Esci"), menu dell'area del ruolo
* (clienti, installatori, admin), avviso di email da confermare, pagina corrente.
* Sotto i 1024 px il menu si apre con un pulsante e si chiude a ogni navigazione.
*/
@Component({
selector: 'app-shell',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './shell.html',
styleUrl: './shell.scss',
})
export class Shell {
protected readonly auth = inject(AuthStore)
private readonly api = inject(ApiService)
protected readonly siteUrl = inject(APP_CONFIG).siteUrl
protected readonly medusaAdminUrl = inject(APP_CONFIG).medusaAdminUrl
protected readonly menu = computed(() => MENUS[this.auth.area() ?? 'customer'])
protected readonly roleLabel = computed(() => {
const role = this.auth.role()
return role ? ROLE_LABELS[role] : ''
})
protected readonly menuOpen = signal(false)
protected readonly resend = signal<'idle' | 'sending' | 'sent' | 'error'>('idle')
protected readonly resendError = signal('')
constructor() {
inject(Router)
.events.pipe(
filter((e) => e instanceof NavigationEnd),
takeUntilDestroyed(inject(DestroyRef)),
)
.subscribe(() => this.menuOpen.set(false))
}
protected toggleMenu(): void {
this.menuOpen.update((open) => !open)
}
protected logout(): void {
void this.auth.logout()
}
protected async resendVerification(): Promise<void> {
this.resend.set('sending')
try {
await firstValueFrom(this.api.post('/auth/verify-email/resend', null))
this.resend.set('sent')
} catch (err) {
const error = toApiError(err)
this.resendError.set(
error.code === 'RATE_LIMITED' ? 'Hai già richiesto più invii: riprova tra qualche minuto.' : error.message,
)
this.resend.set('error')
}
}
}
@@ -0,0 +1,44 @@
import { inject } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { ApiError } from '../../core/api.service'
/** Righe per pagina degli elenchi admin. */
export const ADMIN_PAGE_SIZE = 20
/** `limit`/`offset` per la pagina `page` (da 1). */
export const pageParams = (page: number, size = ADMIN_PAGE_SIZE) => ({ limit: size, offset: (page - 1) * size })
/** Titolo della scheda per le pagine del pannello (da chiamare nel contesto di injection). */
export function adminTitle(): (title: string) => void {
const service = inject(Title)
return (title) => service.setTitle(`${title} | Admin ITI TECHNOLOGY`)
}
/** Risorsa inesistente o id non valido (404 o 400 dell'API). */
export const isNotFound = (err: unknown) => err instanceof ApiError && (err.status === 404 || err.status === 400)
/** Nome e cognome. */
export const fullName = (p: { firstName: string; lastName: string }) => `${p.firstName} ${p.lastName}`.trim()
/**
* Importo in euro scritto dall'utente → centesimi, o null se non valido. Formato italiano ("1.234,50",
* "1234,5", "1.234") e, senza virgola, anche il punto decimale ("1234.50"). Punti seguiti da gruppi di
* 3 cifre = migliaia.
*/
export function parseEuroToCents(text: string): number | null {
let s = text.replace(/[\s€]/g, '')
if (s === '') return null
if (s.includes(',')) {
if (!/^\d{1,3}(\.\d{3})*(,\d{1,2})?$|^\d+(,\d{1,2})?$/.test(s)) return null
s = s.replace(/\./g, '').replace(',', '.')
} else if (/^\d{1,3}(\.\d{3})+$/.test(s)) {
s = s.replace(/\./g, '')
} else if (!/^\d+(\.\d{1,2})?$/.test(s)) {
return null
}
const cents = Math.round(Number(s) * 100)
return Number.isSafeInteger(cents) ? cents : null
}
/** Centesimi → testo modificabile ("1234,50", "1190"). */
export const centsToInput = (cents: number) => (cents % 100 === 0 ? String(cents / 100) : (cents / 100).toFixed(2).replace('.', ','))
@@ -0,0 +1,202 @@
import type { AdminSummaryDto, Paginated, QuoteRequestAdminDto, ShowroomBookingAdminDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { AuthStore } from '../../core/auth.store'
import { formatDateTime } from '../../core/format'
import { BOOKING_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { Empty, Loading, LoadError } from '../../shared/state'
import { adminTitle } from './admin-common'
interface Tile {
label: string
value: number
hint: string
link: string
query: Record<string, string>
}
/**
* Dashboard admin: contatori di ciò che richiede attenzione (ognuno porta all'elenco filtrato), prossime
* visite allo showroom, ultime richieste di preventivo e link al dashboard Medusa per catalogo e ordini.
*/
@Component({
selector: 'app-admin-dashboard',
imports: [RouterLink, Badge, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Ciao {{ auth.user()?.firstName }}</h1>
<p class="lead">Ecco cosa c'è da gestire.</p>
<section class="section" aria-labelledby="todo-title">
<h2 id="todo-title" class="visually-hidden">Da gestire</h2>
@if (summary.isLoading() && !summary.hasValue()) {
<app-loading label="Caricamento dei contatori…" />
} @else if (summary.error()) {
<app-load-error [error]="summary.error()" (retry)="summary.reload()" />
} @else {
<ul class="tiles">
@for (t of tiles(); track t.label) {
<li>
<a class="tile" [class.tile--hot]="t.value > 0" [routerLink]="t.link" [queryParams]="t.query">
<span class="tile__value">{{ t.value }}</span>
<span class="tile__label">{{ t.label }}</span>
<span class="meta">{{ t.hint }}</span>
</a>
</li>
}
</ul>
}
</section>
<div class="split">
<section class="section" aria-labelledby="req-title">
<div class="page-head">
<h2 id="req-title">Nuove richieste di preventivo</h2>
<a routerLink="/admin/richieste">Tutte</a>
</div>
@if (requests.isLoading()) {
<app-loading />
} @else if (requests.error()) {
<app-load-error [error]="requests.error()" (retry)="requests.reload()" />
} @else if (requests.value()?.items?.length) {
<ul class="item-list">
@for (r of requests.value()?.items; track r.id) {
<li>
<a class="item" [routerLink]="['/admin/richieste', r.id]">
<span class="item__main">
<span class="item__title">{{ r.name }}</span>
<span class="meta">{{ r.email }} · {{ r.province ?? 'provincia n.d.' }} · {{ dateTime(r.createdAt) }}</span>
</span>
</a>
</li>
}
</ul>
} @else {
<app-empty message="Nessuna nuova richiesta." />
}
</section>
<section class="section" aria-labelledby="visits-title">
<div class="page-head">
<h2 id="visits-title">Prossime visite allo showroom</h2>
<a routerLink="/admin/showroom">Tutte</a>
</div>
@if (visits.isLoading()) {
<app-loading />
} @else if (visits.error()) {
<app-load-error [error]="visits.error()" (retry)="visits.reload()" />
} @else if (visits.value()?.items?.length) {
<ul class="item-list">
@for (b of visits.value()?.items; track b.id) {
<li>
<a class="item" [routerLink]="['/admin/showroom', b.id]">
<span class="item__main">
<span class="item__title">{{ dateTime(b.requestedAt) }}</span>
<span class="meta">{{ b.name }} · {{ b.people }} {{ b.people === 1 ? 'persona' : 'persone' }}</span>
</span>
<app-badge [status]="bookingLabels[b.status]" />
</a>
</li>
}
</ul>
} @else {
<app-empty message="Nessuna visita in programma." />
}
</section>
</div>
<section class="panel section" aria-labelledby="shop-title">
<h2 id="shop-title">Catalogo, prezzi e ordini</h2>
<p>
Prodotti, categorie, listini (compreso quello degli installatori), giacenze e ordini si gestiscono dal dashboard
di Medusa.
</p>
<a class="btn btn--primary" [href]="medusaAdminUrl" target="_blank" rel="noopener">Apri il dashboard Medusa</a>
</section>
`,
styles: `
@use 'mixins' as mq;
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.tile {
display: flex;
flex-direction: column;
gap: var(--space-1);
height: 100%;
padding: var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface);
color: inherit;
text-decoration: none;
&:hover {
border-color: var(--color-primary);
}
@include mq.focus-ring;
&--hot {
border-left: 4px solid var(--color-accent);
}
&__value {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
line-height: 1;
}
&__label {
font-weight: var(--font-weight-semibold);
}
}
.page-head h2 {
margin: 0;
}
`,
})
export default class AdminDashboard {
private readonly api = inject(ApiService)
protected readonly auth = inject(AuthStore)
protected readonly medusaAdminUrl = inject(APP_CONFIG).medusaAdminUrl
protected readonly summary = rxResource({ stream: () => this.api.get<AdminSummaryDto>('/admin/summary') })
protected readonly requests = rxResource({
stream: () => this.api.get<Paginated<QuoteRequestAdminDto>>('/admin/quote-requests', { query: { status: 'new', limit: 5 } }),
})
/** Visite attive da adesso in poi (le prime 5 in ordine di data). */
protected readonly visits = rxResource({
stream: () =>
this.api.get<Paginated<ShowroomBookingAdminDto>>('/admin/showroom/bookings', {
query: { from: new Date().toISOString(), limit: 8 },
}),
})
protected readonly tiles = computed<Tile[]>(() => {
const s = this.summary.value()
if (!s) return []
return [
{ label: 'Richieste di preventivo', value: s.newQuoteRequests, hint: 'nuove, da contattare', link: '/admin/richieste', query: { stato: 'new' } },
{ label: 'Ticket da gestire', value: s.openTickets, hint: `${s.activeTickets} non chiusi in tutto`, link: '/admin/ticket', query: { stato: 'open' } },
{ label: 'Installatori in attesa', value: s.pendingInstallers, hint: 'profili da verificare', link: '/admin/installatori', query: { stato: 'pending' } },
{ label: 'Visite allo showroom', value: s.upcomingShowroomBookings, hint: 'in programma', link: '/admin/showroom', query: {} },
{ label: 'Messaggi dai contatti', value: s.newContactRequests, hint: 'nuovi', link: '/admin/contatti', query: { stato: 'new' } },
{ label: 'Preventivi inviati', value: s.sentQuotes, hint: 'in attesa di risposta del cliente', link: '/admin/preventivi', query: { stato: 'sent' } },
]
})
protected readonly bookingLabels = BOOKING_STATUS
protected readonly dateTime = formatDateTime
constructor() {
adminTitle()('Dashboard')
}
}
@@ -0,0 +1,292 @@
import type { BlogPostAdminDto, ConfiguratorDefinition, InstallerAdminDto } from '@iti/shared'
import { provideHttpClient, withInterceptors } from '@angular/common/http'
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { provideRouter, Router } from '@angular/router'
import { APP_CONFIG } from '../../core/app-config'
import { authInterceptor } from '../../core/auth.interceptor'
import { formatEuro } from '../../core/format'
import { centsToInput, parseEuroToCents } from './admin-common'
import { postStatus } from './blog'
import AdminBlogEditor, { coverPathFrom, PREVIEW_DEBOUNCE_MS, postBody } from './blog-editor'
import { answerRows, estimateRows, readConfiguratorInput, readConfiguratorResult } from './configurator-summary'
import { documentMetadata } from './documents'
import AdminInstallerDetail from './installer-detail'
import { applyMarkdown, fromLocalInput, IMAGE_ALT_PLACEHOLDER, insertImage, toLocalInput } from './markdown-edit'
import { startOfToday } from './showroom'
const API = 'http://api.test'
const tick = () => new Promise((resolve) => setTimeout(resolve))
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
describe('pannello admin: funzioni', () => {
it('importi in euro scritti a mano → centesimi', () => {
expect(parseEuroToCents('9.900')).toBe(990_000)
expect(parseEuroToCents('1.234,50')).toBe(123_450)
expect(parseEuroToCents('1234,5')).toBe(123_450)
expect(parseEuroToCents('1234.50')).toBe(123_450)
expect(parseEuroToCents(' 5.490 € ')).toBe(549_000)
expect(parseEuroToCents('0')).toBe(0)
for (const bad of ['', 'abc', '12,345', '1.23.4', '-5', '1,2,3', '12.3456']) expect(parseEuroToCents(bad), bad).toBeNull()
expect(centsToInput(549_000)).toBe('5490')
expect(centsToInput(123_450)).toBe('1234,50')
})
it('barra del markdown: grassetto, titoli, link, elenchi', () => {
// selezione avvolta
expect(applyMarkdown('ciao mondo', 5, 10, 'bold')).toEqual({ text: 'ciao **mondo**', start: 7, end: 12 })
// senza selezione: segnaposto selezionato
const empty = applyMarkdown('', 0, 0, 'italic')
expect(empty.text).toBe('_testo in corsivo_')
expect(empty.text.slice(empty.start, empty.end)).toBe('testo in corsivo')
// titolo sulla riga del cursore, sostituisce un titolo esistente
expect(applyMarkdown('uno\ndue\ntre', 5, 5, 'h2').text).toBe('uno\n## due\ntre')
expect(applyMarkdown('### due', 2, 2, 'h2').text).toBe('## due')
// elenco su più righe selezionate
expect(applyMarkdown('a\nb\nc', 0, 3, 'list').text).toBe('- a\n- b\nc')
// link: URL selezionato da sostituire
const link = applyMarkdown('vedi sito', 5, 9, 'link')
expect(link.text).toBe('vedi [sito](https://)')
expect(link.text.slice(link.start, link.end)).toBe('https://')
})
it('immagine caricata: su una riga a sé, con il testo alternativo selezionato', () => {
const r = insertImage('Prima frase.Seconda', 12, 12, 'http://x/images/a.png')
expect(r.text).toBe(`Prima frase.\n\n![${IMAGE_ALT_PLACEHOLDER}](http://x/images/a.png)\n\nSeconda`)
expect(r.text.slice(r.start, r.end)).toBe(IMAGE_ALT_PLACEHOLDER)
expect(insertImage('', 0, 0, 'u').text).toBe(`![${IMAGE_ALT_PLACEHOLDER}](u)`)
})
it('data di pubblicazione: datetime-local ↔ ISO', () => {
const iso = '2026-10-05T12:30:00.000Z'
expect(fromLocalInput(toLocalInput(iso))).toBe(iso)
expect(toLocalInput(null)).toBe('')
expect(fromLocalInput('')).toBeNull()
expect(fromLocalInput('non-una-data')).toBeNull()
})
it('corpo dell’articolo: vuoti → null, slug solo se scritto o cambiato', () => {
const v = {
title: ' Pompe di calore ',
slug: '',
excerpt: ' ',
categoryId: '',
contentMd: '## Testo',
seoTitle: '',
seoDescription: 'Guida',
status: 'published' as const,
publishedAt: '',
}
expect(postBody(v, null, null)).toEqual({
title: 'Pompe di calore',
excerpt: null,
categoryId: null,
contentMd: '## Testo',
coverPath: null,
seoTitle: null,
seoDescription: 'Guida',
status: 'published',
publishedAt: null,
})
const current = { slug: 'pompe-di-calore' } as BlogPostAdminDto
expect(postBody({ ...v, slug: 'pompe-di-calore' }, 'images/2026/10/a.png', current)).not.toHaveProperty('slug')
expect(postBody({ ...v, slug: 'nuovo-slug' }, null, current)).toMatchObject({ slug: 'nuovo-slug' })
expect(coverPathFrom('http://localhost:3000/uploads/images/2026/10/a.png')).toBe('images/2026/10/a.png')
})
it('stato editoriale: data futura = programmato', () => {
const now = Date.parse('2026-10-05T12:00:00Z')
expect(postStatus({ status: 'published', publishedAt: '2026-10-06T00:00:00Z' }, now).label).toBe('Programmato')
expect(postStatus({ status: 'published', publishedAt: '2026-10-01T00:00:00Z' }, now).label).toBe('Pubblicato')
expect(postStatus({ status: 'draft', publishedAt: null }, now).label).toBe('Bozza')
})
it('risposte del configuratore con le etichette della definizione', () => {
const definition: ConfiguratorDefinition = {
version: '1',
provinces: [{ code: 'BG', name: 'Bergamo' }],
steps: [
{
id: 's1',
title: 'Percorso',
questions: [
{ id: 'percorso', type: 'single', label: 'Cosa ti serve?', required: true, options: [{ value: 'acqua', label: 'Acqua più dolce' }] },
{ id: 'persone', type: 'number', label: 'Persone in casa', required: true, unit: 'persone', min: 1, max: 10, step: 1 },
{ id: 'provincia', type: 'province', label: 'Provincia', required: true },
{ id: 'interessi', type: 'multi', label: 'Interessi', required: false, minSelected: 1, options: [{ value: 'fv', label: 'Fotovoltaico' }] },
],
},
],
}
const input = readConfiguratorInput({ definitionVersion: '1', answers: { percorso: 'acqua', persone: 4, provincia: 'BG', interessi: ['fv', 'x'], vecchia: 'v' }, message: 'Ciao' })
expect(input.message).toBe('Ciao')
expect(answerRows(input.answers ?? {}, definition)).toEqual([
{ label: 'Cosa ti serve?', value: 'Acqua più dolce' },
{ label: 'Persone in casa', value: '4 persone' },
{ label: 'Provincia', value: 'Bergamo (BG)' },
{ label: 'Interessi', value: 'Fotovoltaico, x' },
{ label: 'vecchia', value: 'v' },
])
// senza definizione (API giù): id come etichette
expect(answerRows({ percorso: 'acqua' }, undefined)).toEqual([{ label: 'percorso', value: 'acqua' }])
expect(readConfiguratorInput('rotto')).toEqual({})
expect(readConfiguratorResult({ solution: 'x' })).toBeNull()
const result = readConfiguratorResult({
solution: { code: 'a', title: 'Addolcitore', description: '' },
size: null,
estimates: { investmentCents: 150_000, annualSavingsCents: 14_400, paybackYears: 10.4 },
})
expect(estimateRows(result!)).toEqual([
{ label: 'Investimento stimato', value: formatEuro(150_000) },
{ label: 'Risparmio annuo', value: `${formatEuro(14_400)}/anno` },
{ label: 'Rientro', value: '10,4 anni' },
])
})
it('metadati dei documenti e inizio della giornata', () => {
expect(documentMetadata({ title: ' Manuale ', category: '', visibility: 'installer', productRef: ' ' })).toEqual({
title: 'Manuale',
category: null,
visibility: 'installer',
productRef: null,
})
const start = new Date(startOfToday(new Date(2026, 9, 5, 15, 30)))
expect([start.getHours(), start.getMinutes(), start.getDate()]).toEqual([0, 0, 5])
})
})
function setup() {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
// qualunque URL: le pagine navigano dopo il salvataggio
provideRouter([{ path: '**', children: [] }]),
{ provide: APP_CONFIG, useValue: { apiUrl: API, siteUrl: 'http://sito.test', medusaAdminUrl: 'http://medusa.test/app' } },
],
})
return TestBed.inject(HttpTestingController)
}
const button = (el: HTMLElement, text: string) =>
[...el.querySelectorAll('button')].find((b) => b.textContent?.trim().startsWith(text)) as HTMLButtonElement
describe('editor del blog', () => {
async function render() {
const http = setup()
const fixture = TestBed.createComponent(AdminBlogEditor)
fixture.detectChanges()
await tick()
http.expectOne(`${API}/admin/blog/categories`).flush([{ id: 'c1', slug: 'guide', name: 'Guide', description: null }])
await fixture.whenStable()
const el = fixture.nativeElement as HTMLElement
const textarea = el.querySelector('textarea#post-content') as HTMLTextAreaElement
const type = async (value: string) => {
textarea.value = value
textarea.dispatchEvent(new Event('input'))
await fixture.whenStable()
}
return { fixture, http, el, textarea, type }
}
it('anteprima live: una sola richiesta dopo la pausa, HTML dell’API con gli stili del sito', async () => {
const { fixture, http, el, type } = await render()
await type('## Tit')
await wait(PREVIEW_DEBOUNCE_MS / 2)
await type('## Titolo\n\nTesto **forte**')
http.expectNone(`${API}/admin/blog/preview`) // ancora in pausa
await wait(PREVIEW_DEBOUNCE_MS + 50)
const req = http.expectOne(`${API}/admin/blog/preview`)
expect(req.request.body).toEqual({ markdown: '## Titolo\n\nTesto **forte**' })
req.flush({ html: '<h2>Titolo</h2><p>Testo <strong>forte</strong></p>' })
await fixture.whenStable()
const prose = el.querySelector('#pane-preview .prose')
expect(prose?.querySelector('h2')?.textContent).toBe('Titolo')
expect(prose?.querySelector('strong')?.textContent).toBe('forte')
http.verify()
})
it('grassetto sulla selezione e salvataggio come pubblicato', async () => {
const { fixture, http, el, textarea, type } = await render()
;(el.querySelector('input#field-1, app-form-field input') as HTMLInputElement).value = 'Guida alle pompe di calore'
el.querySelector('app-form-field input')?.dispatchEvent(new Event('input'))
await type('Scegli bene')
textarea.setSelectionRange(7, 11)
button(el, 'G').click()
await fixture.whenStable()
expect(textarea.value).toBe('Scegli **bene**')
;(el.querySelector('input[type=radio][value=published]') as HTMLInputElement).click()
await fixture.whenStable()
button(el, 'Salva').click()
await tick()
const req = http.expectOne((r) => r.method === 'POST' && r.url === `${API}/admin/blog/posts`)
expect(req.request.body).toMatchObject({ title: 'Guida alle pompe di calore', contentMd: 'Scegli **bene**', status: 'published', publishedAt: null })
expect(req.request.body).not.toHaveProperty('slug')
req.flush({ id: 'p1' })
await tick()
expect(TestBed.inject(Router).url).toBe('/admin/blog/p1')
// anteprima del testo digitato (pausa scaduta nel frattempo o in arrivo): non interessa qui
await wait(PREVIEW_DEBOUNCE_MS + 50)
http.match(`${API}/admin/blog/preview`)
http.verify()
})
})
describe('dettaglio installatore', () => {
const profile: InstallerAdminDto = {
userId: 'u1',
businessName: 'Impianti Srl',
vatNumber: null,
phone: null,
address: null,
location: null,
serviceRadiusKm: 50,
certifications: [],
status: 'pending',
approvedAt: null,
email: 'inst@example.com',
firstName: 'Ida',
lastName: 'Rossi',
notes: null,
disabled: false,
createdAt: '2026-10-01T10:00:00Z',
}
beforeAll(() => {
// jsdom non implementa <dialog>
HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) {
this.setAttribute('open', '')
}
HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) {
this.removeAttribute('open')
}
})
it('approva: conferma, POST, stato aggiornato; shop giù → avviso di riallineamento', async () => {
const http = setup()
const fixture = TestBed.createComponent(AdminInstallerDetail)
fixture.componentRef.setInput('id', 'u1')
fixture.detectChanges()
await tick()
http.expectOne(`${API}/admin/installers/u1`).flush(profile)
await fixture.whenStable()
const el = fixture.nativeElement as HTMLElement
expect(el.textContent).toContain('In attesa')
button(el, 'Approva').click()
await fixture.whenStable()
expect(el.querySelector('dialog')?.hasAttribute('open')).toBe(true)
;[...el.querySelectorAll('dialog button')].find((b) => b.textContent?.trim() === 'Approva')!.dispatchEvent(new Event('click'))
await tick()
const req = http.expectOne(`${API}/admin/installers/u1/approve`)
expect(req.request.method).toBe('POST')
req.flush({ ...profile, status: 'approved', approvedAt: '2026-10-05T10:00:00Z', medusaSynced: false })
await tick()
await fixture.whenStable()
expect(el.textContent).toContain('Approvato')
expect(el.textContent).toContain('Lo shop non ha risposto')
expect(button(el, 'Approva')).toBeUndefined()
http.verify()
})
})
@@ -0,0 +1,227 @@
import type { BlogCategoryDto } from '@iti/shared'
import { NgTemplateOutlet } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject, signal, viewChild } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { ConfirmDialog } from '../../shared/confirm-dialog'
import { applyServerErrors } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
import { Empty, Loading, LoadError } from '../../shared/state'
import { adminTitle } from './admin-common'
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
function categoryForm() {
return new FormGroup({
name: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }),
slug: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(80), Validators.pattern(SLUG)] }),
description: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(500)] }),
})
}
type CategoryForm = ReturnType<typeof categoryForm>
/** Categorie del blog: ognuna ha la sua pagina sul sito (`/blog/categoria/<slug>`). */
@Component({
selector: 'app-admin-blog-categories',
imports: [NgTemplateOutlet, ReactiveFormsModule, RouterLink, FormField, ConfirmDialog, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/admin/blog" class="back-link">← Blog</a>
<h1>Categorie del blog</h1>
@if (notice(); as n) {
<p class="alert" [class.alert--success]="n.ok" [class.alert--error]="!n.ok" role="status">{{ n.text }}</p>
}
<div class="split">
<section aria-labelledby="list-title">
<h2 id="list-title" class="visually-hidden">Elenco</h2>
@if (res.isLoading() && !res.hasValue()) {
<app-loading />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value()?.length) {
<ul class="item-list">
@for (c of res.value(); track c.id) {
<li class="item">
@if (editing() === c.id) {
<form class="edit" [formGroup]="edit" (ngSubmit)="update(c)" novalidate>
<ng-container *ngTemplateOutlet="fields; context: { form: edit, submitted: true }" />
<div class="actions">
<button type="submit" class="btn btn--primary btn--small" [disabled]="busy()">Salva</button>
<button type="button" class="btn btn--outline btn--small" (click)="editing.set(null)">Annulla</button>
</div>
</form>
} @else {
<span class="item__main">
<span class="item__title">{{ c.name }}</span>
<span class="meta"><a [href]="siteUrl + '/blog/categoria/' + c.slug" target="_blank" rel="noopener">/blog/categoria/{{ c.slug }}</a></span>
@if (c.description) {
<span class="meta">{{ c.description }}</span>
}
</span>
<span class="item__side">
<button type="button" class="link-button" (click)="startEdit(c)">Modifica</button>
<button type="button" class="link-button danger" (click)="askDelete(c)">Elimina</button>
</span>
}
</li>
}
</ul>
} @else {
<app-empty message="Nessuna categoria." />
}
</section>
<form class="panel" [formGroup]="create" (ngSubmit)="add()" novalidate>
<h2>Nuova categoria</h2>
<ng-container *ngTemplateOutlet="fields; context: { form: create, submitted: submitted() }" />
<button type="submit" class="btn btn--primary" [disabled]="busy()">Aggiungi</button>
</form>
</div>
<ng-template #fields let-form="form" let-submitted="submitted">
<app-form-field [control]="form.controls.name" label="Nome" [maxlength]="100" [submitted]="submitted" />
<app-form-field
[control]="form.controls.slug"
label="Indirizzo (slug)"
[maxlength]="80"
[required]="false"
hint="Se vuoto viene generato dal nome. Cambiandolo, i link già condivisi smettono di funzionare."
[messages]="{ pattern: 'Solo lettere minuscole, numeri e trattini' }"
[submitted]="submitted"
/>
<app-form-field
[control]="form.controls.description"
label="Descrizione"
[rows]="3"
[maxlength]="500"
[required]="false"
hint="Introduzione e meta description della pagina della categoria."
[submitted]="submitted"
/>
</ng-template>
<app-confirm-dialog
title="Eliminare la categoria?"
[message]="'«' + (deleting()?.name ?? '') + '» verrà eliminata: i suoi articoli restano, senza categoria.'"
confirmLabel="Elimina"
[danger]="true"
[busy]="busy()"
(confirmed)="remove()"
(cancelled)="deleting.set(null)"
/>
`,
styles: `
h2 {
font-size: var(--font-size-xl);
}
app-form-field {
margin-bottom: var(--space-4);
}
.edit {
width: 100%;
}
.item__side .link-button + .link-button {
margin-left: var(--space-3);
}
.danger {
color: var(--color-danger);
}
`,
})
export default class AdminBlogCategories {
private readonly api = inject(ApiService)
protected readonly siteUrl = inject(APP_CONFIG).siteUrl
protected readonly res = rxResource({ stream: () => this.api.get<BlogCategoryDto[]>('/admin/blog/categories') })
protected readonly create = categoryForm()
protected readonly edit = categoryForm()
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly editing = signal<string | null>(null)
protected readonly deleting = signal<BlogCategoryDto | null>(null)
protected readonly notice = signal<{ ok: boolean; text: string } | null>(null)
private readonly dialog = viewChild(ConfirmDialog)
constructor() {
adminTitle()('Categorie del blog')
}
private body(form: CategoryForm, current?: BlogCategoryDto) {
const v = form.getRawValue()
const slug = v.slug.trim()
return { name: v.name.trim(), ...(slug && slug !== current?.slug ? { slug } : {}), description: v.description.trim() || null }
}
private failed(form: CategoryForm, err: unknown): void {
const e = toApiError(err)
if (e.code === 'SLUG_TAKEN') form.controls.slug.setErrors({ server: 'Indirizzo già usato da un\'altra categoria' })
else if (!applyServerErrors(form, e)) this.notice.set({ ok: false, text: e.message })
}
protected async add(): Promise<void> {
this.submitted.set(true)
this.notice.set(null)
if (this.create.invalid || this.busy()) return
this.busy.set(true)
try {
const c = await firstValueFrom(this.api.post<BlogCategoryDto>('/admin/blog/categories', this.body(this.create)))
this.create.reset()
this.submitted.set(false)
this.notice.set({ ok: true, text: `Categoria «${c.name}» aggiunta.` })
this.res.reload()
} catch (err) {
this.failed(this.create, err)
} finally {
this.busy.set(false)
}
}
protected startEdit(c: BlogCategoryDto): void {
this.notice.set(null)
this.edit.setValue({ name: c.name, slug: c.slug, description: c.description ?? '' })
this.editing.set(c.id)
}
protected async update(c: BlogCategoryDto): Promise<void> {
if (this.edit.invalid || this.busy()) return
this.busy.set(true)
try {
const updated = await firstValueFrom(this.api.patch<BlogCategoryDto>(`/admin/blog/categories/${c.id}`, this.body(this.edit, c)))
this.res.update((list) => list?.map((x) => (x.id === updated.id ? updated : x)))
this.editing.set(null)
this.notice.set({ ok: true, text: 'Categoria aggiornata.' })
} catch (err) {
this.failed(this.edit, err)
} finally {
this.busy.set(false)
}
}
protected askDelete(c: BlogCategoryDto): void {
this.notice.set(null)
this.deleting.set(c)
this.dialog()?.open()
}
protected async remove(): Promise<void> {
const c = this.deleting()
if (!c || this.busy()) return
this.busy.set(true)
try {
await firstValueFrom(this.api.delete(`/admin/blog/categories/${c.id}`))
this.res.update((list) => list?.filter((x) => x.id !== c.id))
this.notice.set({ ok: true, text: `Categoria «${c.name}» eliminata.` })
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
} finally {
this.busy.set(false)
this.deleting.set(null)
this.dialog()?.close()
}
}
}
@@ -0,0 +1,218 @@
<a routerLink="/admin/blog" class="back-link">← Blog</a>
@if (!isNew() && res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento dell'articolo…" />
} @else if (notFound()) {
<h1>Articolo non trovato</h1>
<app-empty message="L'articolo non esiste o è stato eliminato." />
} @else if (res.error()) {
<h1>Articolo</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else {
<div class="page-head">
<h1>{{ isNew() ? 'Nuovo articolo' : 'Modifica articolo' }}</h1>
@if (state(); as s) {
<app-badge [status]="s" />
}
</div>
@if (notice(); as n) {
<p class="alert" [class.alert--success]="n.ok" [class.alert--error]="!n.ok" role="status">
{{ n.text }}
@if (n.ok && publicUrl(); as url) {
<a [href]="url" target="_blank" rel="noopener">Vedi sul sito</a>
}
</p>
}
<form [formGroup]="form" (ngSubmit)="save()" novalidate>
<div class="layout">
<div class="main">
<div class="panel fields">
<app-form-field [control]="form.controls.title" label="Titolo" [maxlength]="200" [submitted]="submitted()" />
<app-form-field
[control]="form.controls.excerpt"
label="Estratto"
[rows]="2"
[maxlength]="500"
[required]="false"
hint="Sotto il titolo e nelle anteprime dell'elenco; se manca si usa l'inizio del testo."
[submitted]="submitted()"
/>
</div>
<div class="editor panel">
<div class="tabs" role="tablist" aria-label="Editor">
<button type="button" role="tab" id="tab-write" aria-controls="pane-write" [attr.aria-selected]="tab() === 'write'" (click)="tab.set('write')">
Scrivi
</button>
<button type="button" role="tab" id="tab-preview" aria-controls="pane-preview" [attr.aria-selected]="tab() === 'preview'" (click)="tab.set('preview')">
Anteprima
</button>
</div>
<div class="panes">
<div class="pane" id="pane-write" role="tabpanel" aria-labelledby="tab-write" [attr.data-active]="tab() === 'write'">
<div class="toolbar-md" role="toolbar" aria-label="Formattazione" aria-controls="post-content">
@for (t of toolbar; track t.action) {
<button type="button" [class]="'tool tool--' + t.action" [title]="t.title" [attr.aria-label]="t.title" (click)="format(t.action)">
{{ t.label }}
</button>
}
<button type="button" class="tool" title="Carica e inserisci un'immagine" (click)="chooseImage()" [disabled]="uploading() === 'image'">
{{ uploading() === 'image' ? 'Caricamento…' : 'Immagine' }}
</button>
<input #imageInput type="file" class="visually-hidden" tabindex="-1" aria-hidden="true" [accept]="imageAccept" (change)="insertUploadedImage($event)" />
</div>
<label for="post-content" class="visually-hidden">Testo dell'articolo (markdown)</label>
<textarea
#content
id="post-content"
formControlName="contentMd"
rows="24"
spellcheck="true"
aria-describedby="content-hint"
(keydown)="shortcut($event)"
></textarea>
<p class="hint" id="content-hint">
Markdown: <code>## Titolo</code>, <code>**grassetto**</code>, <code>_corsivo_</code>, <code>- elenco</code>,
<code>[testo](https://…)</code>. Scorciatoie Ctrl+B, Ctrl+I, Ctrl+K.
</p>
</div>
<div class="pane preview" id="pane-preview" role="tabpanel" aria-labelledby="tab-preview" [attr.data-active]="tab() === 'preview'">
<p class="preview-label" aria-live="polite">
Anteprima
@switch (preview().status) {
@case ('loading') {
<span class="meta">· aggiornamento…</span>
}
@case ('error') {
<span class="field-error">· non disponibile, riprova tra poco</span>
}
}
</p>
<article class="post">
<h1 class="post__title">{{ values().title || 'Titolo dell’articolo' }}</h1>
@if (values().excerpt) {
<p class="post__lead">{{ values().excerpt }}</p>
}
<p class="post__meta">
@if (values().publishedAt) {
{{ date(values().publishedAt) }}
}
@if (categoryName(); as c) {
<span class="post__category">{{ c }}</span>
}
</p>
@if (cover(); as c) {
<div class="post__cover"><img [src]="c.url" alt="" /></div>
}
@if (values().contentMd.trim()) {
<div class="prose" [innerHTML]="previewHtml()"></div>
} @else {
<p class="meta">Il testo che scrivi comparirà qui come sul sito.</p>
}
</article>
</div>
</div>
</div>
</div>
<aside class="side">
<section class="panel">
<h2>Pubblicazione</h2>
<fieldset>
<legend class="visually-hidden">Stato</legend>
<label class="check"><input type="radio" formControlName="status" value="draft" /> Bozza (non visibile sul sito)</label>
<label class="check"><input type="radio" formControlName="status" value="published" /> Pubblicato</label>
</fieldset>
<div class="form-field">
<label for="post-date">Data di pubblicazione <span class="optional">(facoltativa)</span></label>
<p class="hint" id="post-date-hint">Vuota = al momento della pubblicazione. Una data futura programma l'articolo.</p>
<input id="post-date" type="datetime-local" formControlName="publishedAt" aria-describedby="post-date-hint" />
</div>
<div class="actions">
<button type="submit" class="btn btn--primary" [disabled]="busy()">{{ busy() ? 'Salvataggio…' : 'Salva' }}</button>
</div>
@if (publicUrl(); as url) {
<p class="meta"><a [href]="url" target="_blank" rel="noopener">Apri l'articolo sul sito</a></p>
}
</section>
<section class="panel">
<h2>Categoria e copertina</h2>
<div class="form-field">
<label for="post-category">Categoria</label>
<select id="post-category" formControlName="categoryId">
<option value="">Nessuna</option>
@for (c of categories.value() ?? []; track c.id) {
<option [value]="c.id">{{ c.name }}</option>
}
</select>
<p class="hint"><a routerLink="/admin/blog/categorie">Gestisci le categorie</a></p>
</div>
<div class="form-field">
<span class="label">Copertina</span>
@if (cover(); as c) {
<img class="cover-thumb" [src]="c.url" alt="Copertina attuale" />
<button type="button" class="link-button" (click)="removeCover()">Rimuovi la copertina</button>
}
<label for="post-cover" class="file-label">{{ cover() ? 'Sostituisci' : 'Carica un\'immagine' }} <span class="optional">(JPEG, PNG, WebP; 16:9)</span></label>
<input id="post-cover" type="file" [accept]="imageAccept" (change)="uploadCover($event)" [disabled]="uploading() === 'cover'" />
@if (uploading() === 'cover') {
<p class="meta" role="status">Caricamento…</p>
}
</div>
</section>
<section class="panel">
<h2>SEO</h2>
<app-form-field
[control]="form.controls.seoTitle"
label="Titolo per i motori di ricerca"
[maxlength]="70"
[required]="false"
[hint]="'Se vuoto si usa il titolo. ' + values().seoTitle.length + '/70'"
[submitted]="submitted()"
/>
<app-form-field
[control]="form.controls.seoDescription"
label="Descrizione"
[rows]="3"
[maxlength]="160"
[required]="false"
[hint]="'Se vuota si usa l\'estratto. ' + values().seoDescription.length + '/160'"
[submitted]="submitted()"
/>
<app-form-field
[control]="form.controls.slug"
label="Indirizzo (slug)"
[maxlength]="80"
[required]="false"
[hint]="isNew() ? 'Se vuoto viene generato dal titolo.' : 'Cambiandolo, i link già condivisi smettono di funzionare.'"
[messages]="{ pattern: 'Solo lettere minuscole, numeri e trattini (es. pompe-di-calore-guida)' }"
[submitted]="submitted()"
/>
</section>
@if (!isNew()) {
<section class="panel">
<h2>Elimina</h2>
<p class="meta">L'articolo viene cancellato definitivamente.</p>
<button type="button" class="btn btn--danger btn--small" (click)="askDelete()">Elimina l'articolo</button>
</section>
}
</aside>
</div>
</form>
<app-confirm-dialog
title="Eliminare l'articolo?"
[message]="'«' + (post()?.title ?? '') + '» verrà eliminato definitivamente e sparirà dal sito.'"
confirmLabel="Elimina"
[danger]="true"
[busy]="busy()"
(confirmed)="remove()"
/>
}
@@ -0,0 +1,241 @@
@use 'mixins' as mq;
@use 'prose';
.layout {
display: grid;
gap: var(--space-5);
@include mq.up(xl) {
grid-template-columns: minmax(0, 1fr) 22rem;
align-items: start;
}
}
.main {
display: grid;
gap: var(--space-5);
min-width: 0;
}
.fields {
display: grid;
gap: var(--space-4);
}
.side {
display: grid;
gap: var(--space-4);
min-width: 0;
// i campi data e file hanno una larghezza intrinseca: senza questi limiti allargano la colonna
> * {
min-width: 0;
}
input:not([type='radio']),
select {
width: 100%;
min-width: 0;
}
h2 {
font-size: var(--font-size-lg);
}
.form-field + .form-field,
app-form-field + app-form-field {
margin-top: var(--space-4);
}
.actions {
margin-block: var(--space-4) var(--space-2);
}
.label {
font-weight: var(--font-weight-semibold);
}
fieldset {
margin-bottom: var(--space-3);
}
}
.file-label {
margin-top: var(--space-2);
font-weight: var(--font-weight-regular);
}
.cover-thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--radius-md);
background: var(--color-surface-alt);
}
// --- Editor + anteprima ---
.editor {
padding: 0;
overflow: hidden;
// scrittura e anteprima affiancate solo se c'è spazio per entrambe (dipende dal pannello, non dalla finestra)
container-type: inline-size;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--color-line);
button {
flex: 1;
min-height: 44px;
border: 0;
border-bottom: 3px solid transparent;
background: none;
font: inherit;
font-weight: var(--font-weight-semibold);
color: var(--color-ink-muted);
cursor: pointer;
&[aria-selected='true'] {
border-bottom-color: var(--color-primary);
color: var(--color-primary-strong);
}
@include mq.focus-ring;
}
// scrittura e anteprima affiancate: le schede non servono
@container (min-width: 56rem) {
display: none;
}
}
.panes {
@container (min-width: 56rem) {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
.pane {
min-width: 0;
padding: var(--space-4);
&[data-active='false'] {
display: none;
}
@container (min-width: 56rem) {
&[data-active='false'] {
display: block;
}
& + & {
border-left: 1px solid var(--color-line);
}
}
}
.toolbar-md {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.tool {
min-width: 44px;
min-height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--color-line);
border-radius: var(--radius-sm);
background: var(--color-surface);
font: inherit;
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
&--bold {
font-weight: var(--font-weight-bold);
}
&--italic {
font-style: italic;
}
@include mq.focus-ring;
}
textarea {
display: block;
width: 100%;
min-height: 28rem;
padding: var(--space-3);
border: 1px solid var(--color-ink-muted);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.6;
resize: vertical;
}
.hint {
margin: var(--space-2) 0 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
code {
font-family: var(--font-mono);
}
}
// Anteprima: stessa tipografia della pagina articolo del sito (packages/styles/_prose.scss)
.preview {
max-height: 44rem;
overflow-y: auto;
}
.preview-label {
margin: 0 0 var(--space-3);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-ink-muted);
}
.post {
&__title {
font-size: var(--font-size-4xl);
margin-bottom: var(--space-3);
}
&__lead {
font-size: var(--font-size-lg);
color: var(--color-ink-muted);
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
&__category {
font-weight: var(--font-weight-semibold);
color: var(--color-secondary);
}
&__cover {
aspect-ratio: 16 / 9;
margin-bottom: var(--space-6);
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--color-surface-alt);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.prose {
@include prose.container;
}
.prose ::ng-deep {
@include prose.content;
}
@@ -0,0 +1,330 @@
import type { BlogCategoryDto, BlogPostAdminDto, BlogPostStatus } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, type ElementRef, inject, input, linkedSignal, signal, viewChild } from '@angular/core'
import { rxResource, toObservable, toSignal } from '@angular/core/rxjs-interop'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { DomSanitizer } from '@angular/platform-browser'
import { Router, RouterLink } from '@angular/router'
import { catchError, debounceTime, distinctUntilChanged, firstValueFrom, map, of, startWith, switchMap } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { formatDate } from '../../core/format'
import { Badge } from '../../shared/badge'
import { ConfirmDialog } from '../../shared/confirm-dialog'
import { applyServerErrors } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
import { Empty, Loading, LoadError } from '../../shared/state'
import { adminTitle, isNotFound } from './admin-common'
import { postStatus } from './blog'
import { applyMarkdown, fromLocalInput, insertImage, type MarkdownAction, type TextEdit, toLocalInput } from './markdown-edit'
import { multipart, uploadErrorMessage } from './upload'
/** Attesa dopo l'ultima modifica prima di chiedere l'anteprima all'API. */
export const PREVIEW_DEBOUNCE_MS = 400
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const IMAGE_ACCEPT = '.jpg,.jpeg,.png,.webp,.gif'
type Upload = { url: string; path: string }
type PreviewState = { status: 'idle' | 'loading' | 'ready' | 'error'; html: string }
function editorForm() {
return new FormGroup({
title: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(200)] }),
slug: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(80), Validators.pattern(SLUG)] }),
excerpt: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(500)] }),
categoryId: new FormControl('', { nonNullable: true }),
contentMd: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200_000)] }),
seoTitle: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(70)] }),
seoDescription: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(160)] }),
status: new FormControl<BlogPostStatus>('draft', { nonNullable: true }),
publishedAt: new FormControl('', { nonNullable: true }),
})
}
type EditorValue = ReturnType<ReturnType<typeof editorForm>['getRawValue']>
/**
* Corpo di POST/PATCH /admin/blog/posts. Vuoti → null (cancellano il valore). Lo slug va solo se scritto
* (creazione: altrimenti lo genera l'API dal titolo) o cambiato (modifica: gli URL restano stabili).
*/
export function postBody(v: EditorValue, coverPath: string | null, current: BlogPostAdminDto | null) {
const slug = v.slug.trim()
return {
title: v.title.trim(),
...(slug && slug !== current?.slug ? { slug } : {}),
excerpt: v.excerpt.trim() || null,
categoryId: v.categoryId || null,
contentMd: v.contentMd,
coverPath,
seoTitle: v.seoTitle.trim() || null,
seoDescription: v.seoDescription.trim() || null,
status: v.status,
publishedAt: fromLocalInput(v.publishedAt),
}
}
/**
* Editor degli articoli (`/admin/blog/nuovo`, `/admin/blog/:id`): markdown in una textarea con una barra minima
* (grassetto, corsivo, titoli, elenco, citazione, link, immagine caricata), anteprima live con lo stesso HTML
* e gli stessi stili della pagina pubblica (`POST /admin/blog/preview` dopo una pausa di 400 ms), copertina,
* campi SEO, categoria, bozza/pubblicato e data di pubblicazione (una data futura programma l'articolo).
*/
@Component({
selector: 'app-admin-blog-editor',
imports: [ReactiveFormsModule, RouterLink, Badge, ConfirmDialog, FormField, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './blog-editor.html',
styleUrl: './blog-editor.scss',
})
export default class AdminBlogEditor {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly sanitizer = inject(DomSanitizer)
private readonly siteUrl = inject(APP_CONFIG).siteUrl
/** Id dell'articolo; assente su `/admin/blog/nuovo`. */
readonly id = input<string>()
protected readonly res = rxResource({
params: () => this.id(),
stream: ({ params }) => this.api.get<BlogPostAdminDto>(`/admin/blog/posts/${encodeURIComponent(params)}`),
})
protected readonly categories = rxResource({ stream: () => this.api.get<BlogCategoryDto[]>('/admin/blog/categories') })
protected readonly post = computed(() => (this.res.hasValue() ? this.res.value() : null))
protected readonly notFound = computed(() => isNotFound(this.res.error()))
protected readonly isNew = computed(() => !this.id())
protected readonly form = editorForm()
protected readonly cover = signal<Upload | null>(null)
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly uploading = signal<'cover' | 'image' | null>(null)
protected readonly notice = signal<{ ok: boolean; text: string } | null>(
(inject(Router).currentNavigation()?.extras.state as { created?: boolean } | undefined)?.created
? { ok: true, text: 'Articolo creato.' }
: null,
)
protected readonly tab = signal<'write' | 'preview'>('write')
private readonly textarea = viewChild<ElementRef<HTMLTextAreaElement>>('content')
private readonly imageInput = viewChild<ElementRef<HTMLInputElement>>('imageInput')
private readonly dialog = viewChild(ConfirmDialog)
/** Valori del form come signal (anteprima di titolo, estratto, contatori SEO). */
protected readonly values = toSignal(this.form.valueChanges.pipe(map(() => this.form.getRawValue())), {
initialValue: this.form.getRawValue(),
})
/** Anteprima: il markdown va all'API (stesso renderer e sanitizer del sito) dopo una pausa nella scrittura. */
protected readonly preview = toSignal(
toObservable(computed(() => this.values().contentMd)).pipe(
debounceTime(PREVIEW_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((markdown) =>
markdown.trim() === ''
? of<PreviewState>({ status: 'idle', html: '' })
: this.api.post<{ html: string }>('/admin/blog/preview', { markdown }).pipe(
map(({ html }): PreviewState => ({ status: 'ready', html })),
catchError(() => of<PreviewState>({ status: 'error', html: '' })),
startWith<PreviewState>({ status: 'loading', html: '' }),
),
),
),
{ initialValue: { status: 'idle', html: '' } as PreviewState },
)
/** Ultimo HTML valido: resta visibile mentre arriva il successivo (niente sfarfallio). */
private readonly lastHtml = linkedSignal<PreviewState, string>({
source: this.preview,
computation: (p, previous) => (p.status === 'ready' ? p.html : p.status === 'idle' ? '' : (previous?.value ?? '')),
})
// Fidato: HTML sanitizzato dall'API (sanitize-html con whitelist), come nella pagina pubblica
protected readonly previewHtml = computed(() => this.sanitizer.bypassSecurityTrustHtml(this.lastHtml()))
protected readonly publicUrl = computed(() => {
const p = this.post()
return p && p.status === 'published' && p.publishedAt && Date.parse(p.publishedAt) <= Date.now() ? `${this.siteUrl}/blog/${p.slug}` : null
})
protected readonly state = computed(() => {
const p = this.post()
return p ? postStatus(p) : null
})
protected readonly categoryName = computed(() => this.categories.value()?.find((c) => c.id === this.values().categoryId)?.name ?? null)
protected readonly imageAccept = IMAGE_ACCEPT
protected readonly toolbar: { action: MarkdownAction; label: string; title: string }[] = [
{ action: 'bold', label: 'G', title: 'Grassetto' },
{ action: 'italic', label: 'C', title: 'Corsivo' },
{ action: 'h2', label: 'H2', title: 'Titolo di sezione' },
{ action: 'h3', label: 'H3', title: 'Sottotitolo' },
{ action: 'list', label: '• Elenco', title: 'Elenco puntato' },
{ action: 'quote', label: '❝ Citazione', title: 'Citazione' },
{ action: 'link', label: 'Link', title: 'Link' },
]
protected readonly date = formatDate
constructor() {
const setTitle = adminTitle()
effect(() => {
const p = this.post()
setTitle(this.isNew() ? 'Nuovo articolo' : p ? `Modifica: ${p.title}` : 'Articolo')
})
// Carica i dati dell'articolo nel form (anche dopo il salvataggio, con i valori normalizzati dall'API)
effect(() => {
const p = this.post()
if (!p) return
this.form.setValue({
title: p.title,
slug: p.slug,
excerpt: p.excerpt ?? '',
categoryId: p.category?.id ?? '',
contentMd: p.contentMd,
seoTitle: p.seoTitle ?? '',
seoDescription: p.seoDescription ?? '',
status: p.status,
publishedAt: toLocalInput(p.publishedAt),
})
this.form.markAsPristine()
const path = p.coverUrl ? coverPathFrom(p.coverUrl) : null
this.cover.set(p.coverUrl && path ? { url: p.coverUrl, path } : null)
})
}
// --- Barra degli strumenti ---
protected format(action: MarkdownAction): void {
const el = this.textarea()?.nativeElement
if (!el) return
this.applyEdit(applyMarkdown(el.value, el.selectionStart, el.selectionEnd, action))
}
/** Scorciatoie: Ctrl/Cmd+B grassetto, Ctrl/Cmd+I corsivo, Ctrl/Cmd+K link. */
protected shortcut(event: KeyboardEvent): void {
if (!(event.ctrlKey || event.metaKey) || event.altKey) return
const action = ({ b: 'bold', i: 'italic', k: 'link' } as Record<string, MarkdownAction>)[event.key.toLowerCase()]
if (!action) return
event.preventDefault()
this.format(action)
}
protected chooseImage(): void {
this.imageInput()?.nativeElement.click()
}
/** Carica un'immagine nell'area pubblica e la inserisce nel testo alla posizione del cursore. */
protected async insertUploadedImage(event: Event): Promise<void> {
const field = event.target as HTMLInputElement
const file = field.files?.[0]
const el = this.textarea()?.nativeElement
if (!file || !el) return
const { selectionStart, selectionEnd } = el
this.uploading.set('image')
this.notice.set(null)
try {
const up = await firstValueFrom(this.api.post<Upload>('/admin/uploads/image', multipart(file)))
this.applyEdit(insertImage(el.value, selectionStart, selectionEnd, up.url))
} catch (err) {
this.notice.set({ ok: false, text: uploadErrorMessage(toApiError(err)) })
} finally {
field.value = ''
this.uploading.set(null)
}
}
private applyEdit(edit: TextEdit): void {
const control = this.form.controls.contentMd
control.setValue(edit.text)
control.markAsDirty()
this.tab.set('write')
const el = this.textarea()?.nativeElement
if (!el) return
// il valore del controllo arriva nella textarea in modo sincrono (DefaultValueAccessor)
el.focus()
el.setSelectionRange(edit.start, edit.end)
}
// --- Copertina ---
protected async uploadCover(event: Event): Promise<void> {
const field = event.target as HTMLInputElement
const file = field.files?.[0]
if (!file) return
this.uploading.set('cover')
this.notice.set(null)
try {
const up = await firstValueFrom(this.api.post<Upload>('/admin/uploads/image', multipart(file)))
this.cover.set({ url: up.url, path: up.path })
this.form.markAsDirty()
} catch (err) {
this.notice.set({ ok: false, text: uploadErrorMessage(toApiError(err)) })
} finally {
field.value = ''
this.uploading.set(null)
}
}
protected removeCover(): void {
this.cover.set(null)
this.form.markAsDirty()
}
// --- Salvataggio ---
protected async save(status?: BlogPostStatus): Promise<void> {
if (status) this.form.controls.status.setValue(status)
this.submitted.set(true)
this.notice.set(null)
if (this.form.invalid || this.busy()) {
this.tab.set('write')
return
}
const current = this.post()
const body = postBody(this.form.getRawValue(), this.cover()?.path ?? null, current)
this.busy.set(true)
try {
if (current) {
const saved = await firstValueFrom(this.api.patch<BlogPostAdminDto>(`/admin/blog/posts/${current.id}`, body))
this.res.set(saved)
this.submitted.set(false)
this.notice.set({ ok: true, text: savedMessage(saved) })
} else {
const saved = await firstValueFrom(this.api.post<BlogPostAdminDto>('/admin/blog/posts', body))
this.form.markAsPristine()
void this.router.navigate(['/admin/blog', saved.id], { replaceUrl: true, state: { created: true } })
}
} catch (err) {
const e = toApiError(err)
if (e.code === 'SLUG_TAKEN') this.form.controls.slug.setErrors({ server: 'Indirizzo già usato da un altro articolo' })
else if (!applyServerErrors(this.form, e)) this.notice.set({ ok: false, text: e.message })
} finally {
this.busy.set(false)
}
}
protected askDelete(): void {
this.dialog()?.open()
}
protected async remove(): Promise<void> {
const p = this.post()
if (!p || this.busy()) return
this.busy.set(true)
try {
await firstValueFrom(this.api.delete(`/admin/blog/posts/${p.id}`))
this.dialog()?.close()
void this.router.navigate(['/admin/blog'])
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
this.dialog()?.close()
} finally {
this.busy.set(false)
}
}
}
/** `path` da rimandare all'API (`images/…`) a partire dal `coverUrl` del DTO. */
export function coverPathFrom(url: string): string | null {
const i = url.indexOf('/images/')
return i === -1 ? null : url.slice(i + 1)
}
function savedMessage(p: BlogPostAdminDto): string {
if (p.status === 'draft') return 'Bozza salvata: non è visibile sul sito.'
if (p.publishedAt && Date.parse(p.publishedAt) > Date.now()) return `Articolo programmato: comparirà sul sito il ${formatDate(p.publishedAt)}.`
return 'Articolo pubblicato.'
}
+105
View File
@@ -0,0 +1,105 @@
import type { BlogPostAdminDto, BlogPostStatus, Paginated } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { formatDateTime } from '../../core/format'
import { BLOG_STATUS, type StatusLabel } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
import { ADMIN_PAGE_SIZE, adminTitle, pageParams } from './admin-common'
const STATUSES = Object.keys(BLOG_STATUS) as BlogPostStatus[]
const FILTERS: FilterOption[] = [{ value: null, label: 'Tutti' }, ...STATUSES.map((s) => ({ value: s, label: BLOG_STATUS[s].label }))]
/** Stato editoriale: pubblicato con data futura = programmato (non ancora visibile sul sito). */
export function postStatus(p: Pick<BlogPostAdminDto, 'status' | 'publishedAt'>, now = Date.now()): StatusLabel {
if (p.status === 'published' && p.publishedAt && Date.parse(p.publishedAt) > now) return { label: 'Programmato', tone: 'info' }
return BLOG_STATUS[p.status]
}
/** Articoli del blog (bozze comprese), modificati di recente in cima. */
@Component({
selector: 'app-admin-blog',
imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page-head">
<h1>Blog</h1>
<div class="actions">
<a routerLink="/admin/blog/categorie" class="btn btn--outline btn--small">Categorie</a>
<a routerLink="/admin/blog/nuovo" class="btn btn--primary btn--small">Nuovo articolo</a>
</div>
</div>
<div class="toolbar">
<app-filter-pills param="stato" [options]="filters" [current]="status()" label="Filtra per stato" />
<app-search-box [value]="q()" label="Cerca negli articoli" />
</div>
@if (res.isLoading()) {
<app-loading label="Caricamento degli articoli…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th scope="col">Titolo</th>
<th scope="col">Categoria</th>
<th scope="col">Pubblicazione</th>
<th scope="col">Modificato</th>
<th scope="col">Stato</th>
</tr>
</thead>
<tbody>
@for (p of data.items; track p.id) {
<tr>
<td>
<a [routerLink]="['/admin/blog', p.id]">{{ p.title }}</a><br />
<span class="meta">/blog/{{ p.slug }}</span>
</td>
<td>{{ p.category?.name ?? '—' }}</td>
<td>{{ p.publishedAt ? dateTime(p.publishedAt) : '—' }}</td>
<td>{{ dateTime(p.updatedAt) }}</td>
<td><app-badge [status]="state(p)" /></td>
</tr>
}
</tbody>
</table>
</div>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty [message]="q() || status() ? 'Nessun articolo trovato.' : 'Non ci sono ancora articoli.'">
<a routerLink="/admin/blog/nuovo" class="btn btn--outline btn--small">Scrivi il primo</a>
</app-empty>
}
}
`,
})
export default class AdminBlog {
private readonly api = inject(ApiService)
readonly pagina = input<string>()
readonly stato = input<string>()
readonly q = input<string>()
protected readonly filters = FILTERS
protected readonly pageSize = ADMIN_PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly status = computed(() => ((STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as BlogPostStatus) : null))
protected readonly res = rxResource({
params: () => ({ page: this.page(), status: this.status(), q: this.q() }),
stream: ({ params }) =>
this.api.get<Paginated<BlogPostAdminDto>>('/admin/blog/posts', { query: { ...pageParams(params.page), status: params.status, q: params.q } }),
})
protected readonly state = (p: BlogPostAdminDto) => postStatus(p)
protected readonly dateTime = formatDateTime
constructor() {
adminTitle()('Blog')
}
}
@@ -0,0 +1,115 @@
import type { ShowroomBookingAdminDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { formatDateTime } from '../../core/format'
import { BOOKING_STATUS, optionsOf } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { Empty, Loading, LoadError } from '../../shared/state'
import { adminTitle, isNotFound } from './admin-common'
import { StatusSelect } from './status-select'
/**
* Prenotazione dello showroom. Annullata/completata libera i posti; riportarla attiva ricontrolla la capienza
* (lo slot può essersi riempito: 409); il passaggio a "Confermata" invia l'email di conferma al cliente.
*/
@Component({
selector: 'app-admin-booking-detail',
imports: [RouterLink, Badge, StatusSelect, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/admin/showroom" class="back-link">← Prenotazioni showroom</a>
@if (res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento della prenotazione…" />
} @else if (notFound()) {
<h1>Prenotazione non trovata</h1>
<app-empty message="La prenotazione non esiste." />
} @else if (res.error()) {
<h1>Prenotazione</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as b) {
<div class="page-head">
<h1>Visita del {{ dateTime(b.requestedAt) }}</h1>
<app-badge [status]="labels[b.status]" />
</div>
@if (notice(); as n) {
<p class="alert" [class.alert--success]="n.ok" [class.alert--error]="!n.ok" role="status">{{ n.text }}</p>
}
<section class="panel section">
<dl class="facts">
<dt>Visitatore</dt>
<dd>{{ b.name }}</dd>
<dt>Email</dt>
<dd><a [href]="'mailto:' + b.email">{{ b.email }}</a></dd>
<dt>Telefono</dt>
<dd>
@if (b.phone) {
<a [href]="'tel:' + b.phone">{{ b.phone }}</a>
} @else {
—
}
</dd>
<dt>Persone</dt>
<dd>{{ b.people }}</dd>
<dt>Note</dt>
<dd class="pre-line">{{ b.notes || '—' }}</dd>
<dt>Prenotata il</dt>
<dd>{{ dateTime(b.createdAt) }}</dd>
@if (b.userId) {
<dt>Account</dt>
<dd><a [routerLink]="['/admin/utenti', b.userId]">Apri l'utente</a></dd>
}
</dl>
<app-status-select class="status" [value]="b.status" [options]="options" [busy]="busy()" (changed)="setStatus(b, $event)" />
<p class="meta">Confermando una prenotazione il cliente riceve un'email. Annullata o completata libera i posti.</p>
</section>
}
`,
styles: `
.status {
display: block;
margin-block: var(--space-5) var(--space-2);
}
`,
})
export default class AdminBookingDetail {
private readonly api = inject(ApiService)
readonly id = input.required<string>()
protected readonly res = rxResource({
params: () => this.id(),
stream: ({ params }) => this.api.get<ShowroomBookingAdminDto>(`/admin/showroom/bookings/${encodeURIComponent(params)}`),
})
protected readonly notFound = computed(() => isNotFound(this.res.error()))
protected readonly busy = signal(false)
protected readonly notice = signal<{ ok: boolean; text: string } | null>(null)
protected readonly options = optionsOf(BOOKING_STATUS)
protected readonly labels = BOOKING_STATUS
protected readonly dateTime = formatDateTime
constructor() {
const setTitle = adminTitle()
effect(() => setTitle(this.res.value() ? `Visita di ${this.res.value()?.name}` : 'Prenotazione showroom'))
}
protected async setStatus(b: ShowroomBookingAdminDto, status: string): Promise<void> {
this.busy.set(true)
this.notice.set(null)
try {
const updated = await firstValueFrom(this.api.patch<ShowroomBookingAdminDto>(`/admin/showroom/bookings/${b.id}`, { status }))
this.res.set(updated)
this.notice.set({
ok: true,
text: status === 'confirmed' && b.status !== 'confirmed' ? `Prenotazione confermata: email inviata a ${b.email}.` : 'Stato aggiornato.',
})
} catch (err) {
const e = toApiError(err)
this.notice.set({ ok: false, text: e.code === 'SLOT_FULL' ? 'Lo slot nel frattempo si è riempito: non si può riattivare la prenotazione.' : e.message })
} finally {
this.busy.set(false)
}
}
}
@@ -0,0 +1,81 @@
import type { ConfiguratorAnswers, ConfiguratorDefinition, ConfiguratorResult } from '@iti/shared'
import { formatEuro } from '../../core/format'
/** Input salvato dal configuratore in `quote_requests.configurator_input`. */
export interface StoredConfiguratorInput {
definitionVersion?: string
answers?: ConfiguratorAnswers
message?: string
}
export interface AnswerRow {
label: string
value: string
}
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
/** Legge l'input salvato senza fidarsi della forma (righe vecchie o scritte a mano). */
export function readConfiguratorInput(raw: unknown): StoredConfiguratorInput {
if (!isRecord(raw)) return {}
const answers = isRecord(raw['answers']) ? (raw['answers'] as ConfiguratorAnswers) : undefined
return {
...(typeof raw['definitionVersion'] === 'string' ? { definitionVersion: raw['definitionVersion'] } : {}),
...(answers ? { answers } : {}),
...(typeof raw['message'] === 'string' ? { message: raw['message'] } : {}),
}
}
/** Risultato salvato (`configurator_output`), se ha la forma attesa. */
export function readConfiguratorResult(raw: unknown): ConfiguratorResult | null {
return isRecord(raw) && isRecord(raw['solution']) && isRecord(raw['estimates']) ? (raw as unknown as ConfiguratorResult) : null
}
/**
* Risposte in forma leggibile: etichetta della domanda e delle opzioni dalla definizione attuale, nell'ordine
* del wizard. Le risposte a domande che la definizione non ha più restano, con l'id come etichetta.
*/
export function answerRows(answers: ConfiguratorAnswers, definition: ConfiguratorDefinition | undefined): AnswerRow[] {
const rows: AnswerRow[] = []
const seen = new Set<string>()
const provinces = new Map(definition?.provinces.map((p) => [p.code, p.name]) ?? [])
for (const step of definition?.steps ?? []) {
for (const q of step.questions) {
const value = answers[q.id]
if (value === undefined) continue
seen.add(q.id)
let text: string
switch (q.type) {
case 'single':
text = q.options.find((o) => o.value === value)?.label ?? String(value)
break
case 'multi':
text = (Array.isArray(value) ? value : [String(value)]).map((v) => q.options.find((o) => o.value === v)?.label ?? v).join(', ')
break
case 'number':
text = `${typeof value === 'number' ? value.toLocaleString('it-IT') : String(value)} ${q.unit}`.trim()
break
case 'province':
text = provinces.has(String(value)) ? `${provinces.get(String(value))} (${String(value)})` : String(value)
break
}
rows.push({ label: q.label, value: text })
}
}
for (const [id, value] of Object.entries(answers)) {
if (!seen.has(id)) rows.push({ label: id, value: Array.isArray(value) ? value.join(', ') : String(value) })
}
return rows
}
/** Stime del risultato in righe (solo quelle calcolate). */
export function estimateRows(result: ConfiguratorResult): AnswerRow[] {
const rows: AnswerRow[] = []
if (result.size) rows.push({ label: 'Taglia', value: result.size.label })
const e = result.estimates
if (e.investmentCents !== null) rows.push({ label: 'Investimento stimato', value: formatEuro(e.investmentCents) })
if (e.annualSavingsCents !== null) rows.push({ label: 'Risparmio annuo', value: `${formatEuro(e.annualSavingsCents)}/anno` })
if (e.paybackYears !== null) rows.push({ label: 'Rientro', value: `${e.paybackYears.toLocaleString('it-IT')} anni` })
return rows
}
@@ -0,0 +1,113 @@
import type { ContactRequestDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { formatDateTime } from '../../core/format'
import { CONTACT_STATUS, optionsOf } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { Empty, Loading, LoadError } from '../../shared/state'
import { adminTitle, isNotFound } from './admin-common'
import { StatusSelect } from './status-select'
/** Messaggio dal form contatti: testo, contatti per rispondere (email/telefono) e stato di gestione. */
@Component({
selector: 'app-admin-contact-detail',
imports: [RouterLink, Badge, StatusSelect, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/admin/contatti" class="back-link">← Contatti</a>
@if (res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento del messaggio…" />
} @else if (notFound()) {
<h1>Messaggio non trovato</h1>
<app-empty message="Il messaggio non esiste." />
} @else if (res.error()) {
<h1>Messaggio</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as c) {
<div class="page-head">
<h1>{{ c.subject || 'Messaggio senza oggetto' }}</h1>
<app-badge [status]="labels[c.status]" />
</div>
@if (notice(); as n) {
<p class="alert" [class.alert--success]="n.ok" [class.alert--error]="!n.ok" role="status">{{ n.text }}</p>
}
<div class="split">
<section class="panel section" aria-labelledby="msg-title">
<h2 id="msg-title">Messaggio</h2>
<p class="pre-line">{{ c.message }}</p>
</section>
<section class="panel section" aria-labelledby="from-title">
<h2 id="from-title">Mittente</h2>
<dl class="facts">
<dt>Nome</dt>
<dd>{{ c.name }}</dd>
<dt>Email</dt>
<dd><a [href]="replyLink(c)">{{ c.email }}</a></dd>
<dt>Telefono</dt>
<dd>
@if (c.phone) {
<a [href]="'tel:' + c.phone">{{ c.phone }}</a>
} @else {
—
}
</dd>
<dt>Ricevuto</dt>
<dd>{{ dateTime(c.createdAt) }}</dd>
@if (c.userId) {
<dt>Account</dt>
<dd><a [routerLink]="['/admin/utenti', c.userId]">Apri l'utente</a></dd>
}
</dl>
<app-status-select class="status" [value]="c.status" [options]="options" [busy]="busy()" (changed)="setStatus(c, $event)" />
</section>
</div>
}
`,
styles: `
.status {
display: block;
margin-top: var(--space-5);
}
`,
})
export default class AdminContactDetail {
private readonly api = inject(ApiService)
readonly id = input.required<string>()
protected readonly res = rxResource({
params: () => this.id(),
stream: ({ params }) => this.api.get<ContactRequestDto>(`/admin/contact-requests/${encodeURIComponent(params)}`),
})
protected readonly notFound = computed(() => isNotFound(this.res.error()))
protected readonly busy = signal(false)
protected readonly notice = signal<{ ok: boolean; text: string } | null>(null)
protected readonly options = optionsOf(CONTACT_STATUS)
protected readonly labels = CONTACT_STATUS
protected readonly dateTime = formatDateTime
constructor() {
const setTitle = adminTitle()
effect(() => setTitle(this.res.value() ? `Messaggio di ${this.res.value()?.name}` : 'Messaggio'))
}
protected replyLink(c: ContactRequestDto): string {
return `mailto:${c.email}?subject=${encodeURIComponent(`Re: ${c.subject || 'la tua richiesta a ITI TECHNOLOGY'}`)}`
}
protected async setStatus(c: ContactRequestDto, status: string): Promise<void> {
this.busy.set(true)
this.notice.set(null)
try {
this.res.set(await firstValueFrom(this.api.patch<ContactRequestDto>(`/admin/contact-requests/${c.id}`, { status })))
this.notice.set({ ok: true, text: 'Stato aggiornato.' })
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
} finally {
this.busy.set(false)
}
}
}
@@ -0,0 +1,88 @@
import type { ContactRequestDto, ContactRequestStatus, Paginated } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { formatDateTime } from '../../core/format'
import { CONTACT_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
import { ADMIN_PAGE_SIZE, adminTitle, pageParams } from './admin-common'
const STATUSES = Object.keys(CONTACT_STATUS) as ContactRequestStatus[]
const FILTERS: FilterOption[] = [{ value: null, label: 'Tutte' }, ...STATUSES.map((s) => ({ value: s, label: CONTACT_STATUS[s].label }))]
/** Messaggi dal form contatti del sito. */
@Component({
selector: 'app-admin-contacts',
imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Contatti</h1>
<div class="toolbar">
<app-filter-pills param="stato" [options]="filters" [current]="status()" label="Filtra per stato" />
<app-search-box [value]="q()" label="Cerca per nome, email o oggetto" />
</div>
@if (res.isLoading()) {
<app-loading label="Caricamento dei messaggi…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<ul class="item-list">
@for (c of data.items; track c.id) {
<li>
<a class="item" [routerLink]="['/admin/contatti', c.id]">
<span class="item__main">
<span class="item__title">{{ c.subject || 'Senza oggetto' }}</span>
<span class="meta">{{ c.name }} · {{ c.email }} · {{ dateTime(c.createdAt) }}</span>
<span class="excerpt">{{ excerpt(c.message) }}</span>
</span>
<app-badge [status]="labels[c.status]" />
</a>
</li>
}
</ul>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty message="Nessun messaggio trovato." />
}
}
`,
styles: `
.excerpt {
color: var(--color-ink-muted);
overflow-wrap: anywhere;
}
`,
})
export default class AdminContacts {
private readonly api = inject(ApiService)
readonly pagina = input<string>()
readonly stato = input<string>()
readonly q = input<string>()
protected readonly filters = FILTERS
protected readonly pageSize = ADMIN_PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly status = computed(() => ((STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as ContactRequestStatus) : null))
protected readonly res = rxResource({
params: () => ({ page: this.page(), status: this.status(), q: this.q() }),
stream: ({ params }) =>
this.api.get<Paginated<ContactRequestDto>>('/admin/contact-requests', { query: { ...pageParams(params.page), status: params.status, q: params.q } }),
})
protected readonly labels = CONTACT_STATUS
protected readonly dateTime = formatDateTime
protected excerpt(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim()
return flat.length > 140 ? `${flat.slice(0, 139)}…` : flat
}
constructor() {
adminTitle()('Contatti')
}
}
@@ -0,0 +1,344 @@
import type { DocumentDto, DocumentVisibility, Paginated } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, type ElementRef, inject, input, signal, viewChild } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { DownloadService, fileName } from '../../core/download.service'
import { formatBytes, formatDate } from '../../core/format'
import { optionsOf, VISIBILITY_LABELS } from '../../core/labels'
import { ConfirmDialog } from '../../shared/confirm-dialog'
import { applyServerErrors } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
import { adminTitle } from './admin-common'
import { multipart, uploadErrorMessage } from './upload'
const PAGE_SIZE = 50
const VISIBILITIES = Object.keys(VISIBILITY_LABELS) as DocumentVisibility[]
const FILTERS: FilterOption[] = [{ value: null, label: 'Tutti' }, ...VISIBILITIES.map((v) => ({ value: v, label: VISIBILITY_LABELS[v] }))]
/** Estensioni ammesse dall'API (verifica vera lato server sui magic bytes). */
export const DOCUMENT_ACCEPT = '.pdf,.zip,.dwg,.dxf,.jpg,.jpeg,.png,.webp,.gif'
const HANDLE = /^[a-z0-9-]+$/
function metadataGroup() {
return new FormGroup({
title: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(200)] }),
category: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(100)] }),
visibility: new FormControl<DocumentVisibility | ''>('', { nonNullable: true, validators: [Validators.required] }),
productRef: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200), Validators.pattern(HANDLE)] }),
})
}
type MetadataForm = ReturnType<typeof metadataGroup>
/** Metadati per l'API: campi facoltativi vuoti → null (nel PATCH cancellano il valore). */
export function documentMetadata(v: ReturnType<MetadataForm['getRawValue']>) {
return {
title: v.title.trim(),
category: v.category.trim() || null,
visibility: v.visibility,
productRef: v.productRef.trim() || null,
}
}
/**
* Documenti: caricamento con visibilità per ruolo, modifica dei metadati, eliminazione (riga e file).
* I PDF dei preventivi sono documenti "Solo staff" in categoria `preventivi`: si gestiscono dal preventivo.
*/
@Component({
selector: 'app-admin-documents',
imports: [ReactiveFormsModule, FormField, FilterPills, SearchBox, Pager, ConfirmDialog, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Documenti</h1>
<details class="panel section upload" [open]="uploadOpen()" (toggle)="uploadOpen.set($any($event.target).open)">
<summary>Carica un documento</summary>
<form [formGroup]="upload" (ngSubmit)="create()" novalidate>
<div class="form-grid">
<div class="form-field span-2">
<label for="doc-file">File</label>
<p class="hint" id="doc-file-hint">PDF, ZIP, DWG, DXF o immagini; il tipo viene verificato sul contenuto.</p>
<input #fileInput id="doc-file" type="file" [accept]="accept" aria-describedby="doc-file-hint" (change)="pick($event)" />
@if (fileError(); as e) {
<p class="error">{{ e }}</p>
}
</div>
<app-form-field [control]="upload.controls.title" label="Titolo" [maxlength]="200" [submitted]="submitted()" />
<div class="form-field">
<label for="doc-visibility">Chi può vederlo</label>
<select id="doc-visibility" formControlName="visibility" [attr.aria-invalid]="submitted() && upload.controls.visibility.invalid ? 'true' : null">
<option value="" disabled>Scegli…</option>
@for (o of visibilityOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
@if (submitted() && upload.controls.visibility.invalid) {
<p class="error">Scegli chi può vedere il documento</p>
}
</div>
<app-form-field [control]="upload.controls.category" label="Categoria" [maxlength]="100" [required]="false"
hint="Es. Manuali, Schede tecniche, CAD, Certificazioni" />
<app-form-field [control]="upload.controls.productRef" label="Prodotto (handle dello shop)" [maxlength]="200" [required]="false"
hint="Es. pompa-di-calore-aria-acqua-8kw: il documento compare nella scheda prodotto"
[messages]="{ pattern: 'Solo lettere minuscole, numeri e trattini' }" />
</div>
@if (uploadError(); as e) {
<p class="alert alert--error" role="alert">{{ e }}</p>
}
<button type="submit" class="btn btn--primary" [disabled]="busy()">{{ busy() ? 'Caricamento…' : 'Carica' }}</button>
</form>
</details>
@if (notice(); as n) {
<p class="alert" [class.alert--success]="n.ok" [class.alert--error]="!n.ok" role="status">{{ n.text }}</p>
}
<div class="toolbar">
<app-filter-pills param="visibilita" [options]="filters" [current]="visibility()" label="Filtra per visibilità" />
<app-search-box [value]="q()" label="Cerca per titolo" />
</div>
@if (res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento dei documenti…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th scope="col">Titolo</th>
<th scope="col">Categoria</th>
<th scope="col">Visibilità</th>
<th scope="col">Prodotto</th>
<th scope="col" class="num">Dimensione</th>
<th scope="col">Caricato</th>
<th scope="col"><span class="visually-hidden">Azioni</span></th>
</tr>
</thead>
<tbody>
@for (d of data.items; track d.id) {
<tr>
<td>{{ d.title }}</td>
<td>{{ d.category ?? '—' }}</td>
<td>{{ visibilityLabels[d.visibility] }}</td>
<td>{{ d.productRef ?? '—' }}</td>
<td class="num">{{ bytes(d.sizeBytes) }}</td>
<td>{{ date(d.createdAt) }}</td>
<td class="row-actions">
<button type="button" class="link-button" (click)="download(d)">Scarica</button>
<button type="button" class="link-button" (click)="startEdit(d)" [attr.aria-expanded]="editing() === d.id">Modifica</button>
<button type="button" class="link-button danger" (click)="askDelete(d)">Elimina</button>
</td>
</tr>
@if (editing() === d.id) {
<tr class="edit-row">
<td colspan="7">
<form [formGroup]="edit" (ngSubmit)="saveEdit(d)" novalidate>
<div class="form-grid">
<app-form-field [control]="edit.controls.title" label="Titolo" [maxlength]="200" [submitted]="true" />
<div class="form-field">
<label for="edit-visibility">Chi può vederlo</label>
<select id="edit-visibility" formControlName="visibility">
@for (o of visibilityOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<app-form-field [control]="edit.controls.category" label="Categoria" [maxlength]="100" [required]="false" />
<app-form-field [control]="edit.controls.productRef" label="Prodotto (handle)" [maxlength]="200" [required]="false"
[messages]="{ pattern: 'Solo lettere minuscole, numeri e trattini' }" [submitted]="true" />
</div>
<div class="actions">
<button type="submit" class="btn btn--primary btn--small" [disabled]="busy()">Salva</button>
<button type="button" class="btn btn--outline btn--small" (click)="editing.set(null)">Annulla</button>
</div>
</form>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty message="Nessun documento trovato." />
}
}
<app-confirm-dialog
title="Eliminare il documento?"
[message]="'«' + (deleting()?.title ?? '') + '» verrà eliminato insieme al file. L\\'operazione non si può annullare.'"
confirmLabel="Elimina"
[danger]="true"
[busy]="busy()"
(confirmed)="remove()"
(cancelled)="deleting.set(null)"
/>
`,
styles: `
.upload summary {
min-height: 44px;
cursor: pointer;
font-weight: var(--font-weight-semibold);
}
.upload form {
margin-top: var(--space-4);
}
.form-grid {
margin-bottom: var(--space-4);
}
.row-actions {
white-space: nowrap;
.link-button + .link-button {
margin-left: var(--space-3);
}
}
.danger {
color: var(--color-danger);
}
.edit-row td {
background: var(--color-surface-alt);
}
`,
})
export default class AdminDocuments {
private readonly api = inject(ApiService)
private readonly downloads = inject(DownloadService)
readonly pagina = input<string>()
readonly visibilita = input<string>()
readonly q = input<string>()
protected readonly filters = FILTERS
protected readonly pageSize = PAGE_SIZE
protected readonly accept = DOCUMENT_ACCEPT
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly visibility = computed(() =>
(VISIBILITIES as string[]).includes(this.visibilita() ?? '') ? (this.visibilita() as DocumentVisibility) : null,
)
protected readonly res = rxResource({
params: () => ({ page: this.page(), visibility: this.visibility(), q: this.q() }),
stream: ({ params }) =>
this.api.get<Paginated<DocumentDto>>('/admin/documents', {
query: { limit: PAGE_SIZE, offset: (params.page - 1) * PAGE_SIZE, visibility: params.visibility, q: params.q },
}),
})
protected readonly upload = metadataGroup()
protected readonly edit = metadataGroup()
private file: File | null = null
protected readonly uploadOpen = signal(false)
protected readonly submitted = signal(false)
protected readonly fileError = signal<string | null>(null)
protected readonly uploadError = signal<string | null>(null)
protected readonly busy = signal(false)
protected readonly notice = signal<{ ok: boolean; text: string } | null>(null)
protected readonly editing = signal<string | null>(null)
protected readonly deleting = signal<DocumentDto | null>(null)
private readonly dialog = viewChild(ConfirmDialog)
private readonly fileInput = viewChild<ElementRef<HTMLInputElement>>('fileInput')
protected readonly visibilityOptions = optionsOf(VISIBILITY_LABELS)
protected readonly visibilityLabels = VISIBILITY_LABELS
protected readonly bytes = formatBytes
protected readonly date = formatDate
constructor() {
adminTitle()('Documenti')
}
protected pick(event: Event): void {
this.file = (event.target as HTMLInputElement).files?.[0] ?? null
this.fileError.set(null)
// titolo proposto dal nome del file, se non è già stato scritto
if (this.file && !this.upload.controls.title.value) {
this.upload.controls.title.setValue(this.file.name.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim())
}
}
protected async create(): Promise<void> {
this.submitted.set(true)
this.uploadError.set(null)
this.notice.set(null)
if (!this.file) this.fileError.set('Seleziona un file')
if (this.upload.invalid || !this.file || this.busy()) return
this.busy.set(true)
try {
const meta = documentMetadata(this.upload.getRawValue())
const doc = await firstValueFrom(this.api.post<DocumentDto>('/admin/documents', multipart(this.file, meta)))
this.upload.reset()
this.file = null
const field = this.fileInput()?.nativeElement
if (field) field.value = ''
this.submitted.set(false)
this.uploadOpen.set(false)
this.notice.set({ ok: true, text: `Documento «${doc.title}» caricato (${VISIBILITY_LABELS[doc.visibility].toLowerCase()}).` })
this.res.reload()
} catch (err) {
const e = toApiError(err)
if (!applyServerErrors(this.upload, e)) this.uploadError.set(uploadErrorMessage(e))
} finally {
this.busy.set(false)
}
}
protected startEdit(d: DocumentDto): void {
this.notice.set(null)
this.edit.setValue({ title: d.title, category: d.category ?? '', visibility: d.visibility, productRef: d.productRef ?? '' })
this.editing.set(this.editing() === d.id ? null : d.id)
}
protected async saveEdit(d: DocumentDto): Promise<void> {
if (this.edit.invalid || this.busy()) return
this.busy.set(true)
try {
const updated = await firstValueFrom(this.api.patch<DocumentDto>(`/admin/documents/${d.id}`, documentMetadata(this.edit.getRawValue())))
this.res.update((page) => page && { ...page, items: page.items.map((x) => (x.id === updated.id ? updated : x)) })
this.editing.set(null)
this.notice.set({ ok: true, text: 'Documento aggiornato.' })
} catch (err) {
const e = toApiError(err)
if (!applyServerErrors(this.edit, e)) this.notice.set({ ok: false, text: e.message })
} finally {
this.busy.set(false)
}
}
protected askDelete(d: DocumentDto): void {
this.notice.set(null)
this.deleting.set(d)
this.dialog()?.open()
}
protected async remove(): Promise<void> {
const d = this.deleting()
if (!d || this.busy()) return
this.busy.set(true)
try {
await firstValueFrom(this.api.delete(`/admin/documents/${d.id}`))
this.notice.set({ ok: true, text: `Documento «${d.title}» eliminato.` })
this.res.reload()
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
} finally {
this.busy.set(false)
this.deleting.set(null)
this.dialog()?.close()
}
}
protected async download(d: DocumentDto): Promise<void> {
try {
await this.downloads.download(`/documents/${d.id}/download`, fileName(d.title, d.mimeType))
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
}
}
}
@@ -0,0 +1,239 @@
import type { InstallerAdminDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, viewChild } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { formatDate, formatDateTime } from '../../core/format'
import { INSTALLER_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { ConfirmDialog } from '../../shared/confirm-dialog'
import { FormField } from '../../shared/form-field'
import { Empty, Loading, LoadError } from '../../shared/state'
import { adminTitle, fullName, isNotFound } from './admin-common'
type Decision = 'approve' | 'reject'
type DecisionResult = InstallerAdminDto & { medusaSynced: boolean }
/** Link di OpenStreetMap centrato su un punto (nessuna richiesta a terzi finché non lo si apre). */
export const osmLink = (p: { lat: number; lng: number }) =>
`https://www.openstreetmap.org/?mlat=${p.lat}&mlon=${p.lng}#map=12/${p.lat}/${p.lng}`
/**
* Profilo installatore lato staff: dati aziendali, zona, certificazioni, note interne, approvazione o rifiuto
* (con email all'installatore e allineamento del listino riservato in Medusa).
*/
@Component({
selector: 'app-admin-installer-detail',
imports: [ReactiveFormsModule, RouterLink, Badge, ConfirmDialog, FormField, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/admin/installatori" class="back-link">← Installatori</a>
@if (res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento del profilo…" />
} @else if (notFound()) {
<h1>Installatore non trovato</h1>
<app-empty message="Il profilo non esiste: l'utente potrebbe non aver mai compilato il profilo installatore." />
} @else if (res.error()) {
<h1>Installatore</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as i) {
<div class="page-head">
<h1>{{ i.businessName }}</h1>
<app-badge [status]="labels[i.status]" />
</div>
@if (notice(); as n) {
<p class="alert" [class.alert--success]="n.ok" [class.alert--error]="!n.ok" role="status">{{ n.text }}</p>
}
<div class="split">
<div>
<section class="panel section" aria-labelledby="data-title">
<h2 id="data-title">Dati aziendali</h2>
<dl class="facts">
<dt>Referente</dt>
<dd><a [routerLink]="['/admin/utenti', i.userId]">{{ name(i) }}</a> · {{ i.email }}</dd>
<dt>P.IVA</dt>
<dd>{{ i.vatNumber ?? '—' }}</dd>
<dt>Telefono</dt>
<dd>{{ i.phone ?? '—' }}</dd>
<dt>Indirizzo</dt>
<dd>{{ i.address ?? '—' }}</dd>
<dt>Zona</dt>
<dd>
@if (i.location; as loc) {
raggio {{ i.serviceRadiusKm }} km da {{ loc.lat }}, {{ loc.lng }} ·
<a [href]="osm(loc)" target="_blank" rel="noopener">vedi sulla mappa</a>
} @else {
sede non indicata (non compare nelle ricerche per zona)
}
</dd>
<dt>Certificazioni</dt>
<dd>{{ i.certifications.length ? i.certifications.join(', ') : '—' }}</dd>
<dt>Registrato</dt>
<dd>{{ dateTime(i.createdAt) }}</dd>
@if (i.approvedAt) {
<dt>Approvato il</dt>
<dd>{{ date(i.approvedAt) }}</dd>
}
@if (i.disabled) {
<dt>Account</dt>
<dd>disabilitato</dd>
}
</dl>
</section>
<form class="panel section" (submit)="$event.preventDefault(); saveNotes(i)" novalidate>
<h2>Note interne</h2>
<app-form-field [control]="notes" label="Note" [rows]="4" [maxlength]="5000" [required]="false"
hint="Visibili solo allo staff." />
<button type="submit" class="btn btn--outline btn--small" [disabled]="busy() || notes.value === (i.notes ?? '')">Salva le note</button>
</form>
</div>
<section class="panel section" aria-labelledby="decision-title">
<h2 id="decision-title">Decisione</h2>
@switch (i.status) {
@case ('pending') {
<p>Verifica i dati e la documentazione, poi approva: l'installatore vedrà subito listino riservato e documentazione tecnica.</p>
}
@case ('approved') {
<p>Approvato: vede il listino riservato e i documenti per installatori. Rifiutandolo li perde subito.</p>
}
@case ('rejected') {
<p>Rifiutato. Puoi approvarlo se nel frattempo ha completato i requisiti.</p>
}
}
<div class="actions">
@if (i.status !== 'approved') {
<button type="button" class="btn btn--primary" (click)="ask('approve')">Approva</button>
}
@if (i.status !== 'rejected') {
<button type="button" class="btn btn--outline" (click)="ask('reject')">Rifiuta</button>
}
</div>
<p class="meta sync">
Listino non allineato?
<button type="button" class="link-button" (click)="sync(i)" [disabled]="busy()">Riallinea con lo shop</button>
</p>
</section>
</div>
<app-confirm-dialog
[title]="decision() === 'approve' ? 'Approvare l\\'installatore?' : 'Rifiutare l\\'installatore?'"
[message]="
decision() === 'approve'
? i.businessName + ' riceverà un\\'email e avrà accesso a listino riservato e documentazione tecnica.'
: i.businessName + ' riceverà un\\'email con il motivo indicato e perderà l\\'accesso riservato.'
"
[confirmLabel]="decision() === 'approve' ? 'Approva' : 'Rifiuta'"
[danger]="decision() === 'reject'"
[busy]="busy()"
(confirmed)="decide(i)"
(cancelled)="decision.set(null)"
>
@if (decision() === 'reject') {
<app-form-field [control]="reason" label="Motivo (nell'email)" [rows]="3" [maxlength]="1000" [required]="false" />
}
</app-confirm-dialog>
}
`,
styles: `
.sync {
margin: var(--space-4) 0 0;
}
`,
})
export default class AdminInstallerDetail {
private readonly api = inject(ApiService)
readonly id = input.required<string>()
protected readonly res = rxResource({
params: () => this.id(),
stream: ({ params }) => this.api.get<InstallerAdminDto>(`/admin/installers/${encodeURIComponent(params)}`),
})
protected readonly notFound = computed(() => isNotFound(this.res.error()))
protected readonly notes = new FormControl('', { nonNullable: true, validators: [Validators.maxLength(5000)] })
protected readonly reason = new FormControl('', { nonNullable: true, validators: [Validators.maxLength(1000)] })
protected readonly decision = signal<Decision | null>(null)
protected readonly busy = signal(false)
protected readonly notice = signal<{ ok: boolean; text: string } | null>(null)
private readonly dialog = viewChild(ConfirmDialog)
protected readonly labels = INSTALLER_STATUS
protected readonly date = formatDate
protected readonly dateTime = formatDateTime
protected readonly name = fullName
protected readonly osm = osmLink
constructor() {
const setTitle = adminTitle()
effect(() => {
const i = this.res.value()
setTitle(i ? i.businessName : 'Installatore')
if (i) this.notes.setValue(i.notes ?? '')
})
}
protected ask(decision: Decision): void {
this.notice.set(null)
this.reason.reset()
this.decision.set(decision)
this.dialog()?.open()
}
protected async decide(i: InstallerAdminDto): Promise<void> {
const decision = this.decision()
if (!decision || this.busy()) return
this.busy.set(true)
try {
const reason = this.reason.value.trim()
const result = await firstValueFrom(
this.api.post<DecisionResult>(`/admin/installers/${i.userId}/${decision}`, decision === 'reject' && reason ? { reason } : {}),
)
const { medusaSynced, ...profile } = result
this.res.set(profile)
const text = decision === 'approve' ? 'Installatore approvato: email inviata.' : 'Installatore rifiutato: email inviata.'
this.notice.set(
medusaSynced
? { ok: true, text }
: { ok: false, text: `${text} Lo shop non ha risposto: il listino si allineerà al suo prossimo accesso, oppure usa "Riallinea con lo shop".` },
)
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
} finally {
this.busy.set(false)
this.decision.set(null)
this.dialog()?.close()
}
}
protected async saveNotes(i: InstallerAdminDto): Promise<void> {
this.busy.set(true)
this.notice.set(null)
try {
this.res.set(await firstValueFrom(this.api.patch<InstallerAdminDto>(`/admin/installers/${i.userId}`, { notes: this.notes.value })))
this.notice.set({ ok: true, text: 'Note salvate.' })
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
} finally {
this.busy.set(false)
}
}
protected async sync(i: InstallerAdminDto): Promise<void> {
this.busy.set(true)
this.notice.set(null)
try {
const { member } = await firstValueFrom(this.api.post<{ member: boolean }>(`/admin/installers/${i.userId}/sync`, null))
this.notice.set({ ok: true, text: member ? 'Allineato: listino riservato attivo.' : 'Allineato: nessun listino riservato.' })
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
} finally {
this.busy.set(false)
}
}
}
@@ -0,0 +1,102 @@
import type { InstallerAdminDto, InstallerStatus, Paginated } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { formatDate } from '../../core/format'
import { INSTALLER_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
import { ADMIN_PAGE_SIZE, adminTitle, fullName, pageParams } from './admin-common'
const STATUSES = Object.keys(INSTALLER_STATUS) as InstallerStatus[]
const FILTERS: FilterOption[] = [{ value: null, label: 'Tutti' }, ...STATUSES.map((s) => ({ value: s, label: INSTALLER_STATUS[s].label }))]
/** Installatori per stato del profilo, ricerca per ragione sociale o email. */
@Component({
selector: 'app-admin-installers',
imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Installatori</h1>
<div class="toolbar">
<app-filter-pills param="stato" [options]="filters" [current]="status()" label="Filtra per stato" />
<app-search-box [value]="q()" label="Cerca per ragione sociale o email" />
</div>
@if (res.isLoading()) {
<app-loading label="Caricamento degli installatori…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th scope="col">Ragione sociale</th>
<th scope="col">Referente</th>
<th scope="col">Zona</th>
<th scope="col">Registrato</th>
<th scope="col">Stato</th>
</tr>
</thead>
<tbody>
@for (i of data.items; track i.userId) {
<tr>
<td>
<a [routerLink]="['/admin/installatori', i.userId]">{{ i.businessName }}</a>
@if (i.vatNumber) {
<br /><span class="meta">P.IVA {{ i.vatNumber }}</span>
}
</td>
<td>
{{ name(i) }}<br />
<span class="meta">{{ i.email }}</span>
</td>
<td>{{ i.location ? 'raggio ' + i.serviceRadiusKm + ' km' : 'sede non indicata' }}</td>
<td>{{ date(i.createdAt) }}</td>
<td>
<app-badge [status]="labels[i.status]" />
@if (i.disabled) {
<br /><span class="meta">account disabilitato</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty message="Nessun installatore trovato." />
}
}
`,
})
export default class AdminInstallers {
private readonly api = inject(ApiService)
readonly pagina = input<string>()
readonly stato = input<string>()
readonly q = input<string>()
protected readonly filters = FILTERS
protected readonly pageSize = ADMIN_PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly status = computed(() => ((STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as InstallerStatus) : null))
protected readonly res = rxResource({
params: () => ({ page: this.page(), status: this.status(), q: this.q() }),
stream: ({ params }) =>
this.api.get<Paginated<InstallerAdminDto>>('/admin/installers', { query: { ...pageParams(params.page), status: params.status, q: params.q } }),
})
protected readonly labels = INSTALLER_STATUS
protected readonly date = formatDate
protected readonly name = fullName
constructor() {
adminTitle()('Installatori')
}
}
@@ -0,0 +1,92 @@
/**
* Modifiche al testo markdown dell'editor del blog (funzioni pure: testo + selezione → testo + nuova selezione).
* La selezione restituita evidenzia la parte da completare (es. l'URL di un link), così si scrive subito sopra.
*/
export interface TextEdit {
text: string
/** Inizio e fine della selezione dopo la modifica. */
start: number
end: number
}
export type MarkdownAction = 'bold' | 'italic' | 'h2' | 'h3' | 'link' | 'list' | 'quote'
const LINK_PLACEHOLDER = 'https://'
/** Avvolge la selezione con `mark` (es. `**`); senza selezione inserisce `mark` + `placeholder` + `mark` selezionando il segnaposto. */
function wrap(text: string, start: number, end: number, mark: string, placeholder: string): TextEdit {
const selected = text.slice(start, end)
const inner = selected || placeholder
const next = `${text.slice(0, start)}${mark}${inner}${mark}${text.slice(end)}`
return { text: next, start: start + mark.length, end: start + mark.length + inner.length }
}
/** Applica `prefix` all'inizio di ogni riga toccata dalla selezione (titoli, elenchi, citazioni); i titoli sostituiscono quelli esistenti. */
function prefixLines(text: string, start: number, end: number, prefix: string, heading: boolean): TextEdit {
const lineStart = text.lastIndexOf('\n', start - 1) + 1
const lineEndIdx = text.indexOf('\n', end)
const lineEnd = lineEndIdx === -1 ? text.length : lineEndIdx
const lines = text.slice(lineStart, lineEnd).split('\n')
const changed = lines.map((line) => {
const base = heading ? line.replace(/^#{1,6}\s+/, '') : line
return `${prefix}${base}`
})
const block = changed.join('\n')
return { text: `${text.slice(0, lineStart)}${block}${text.slice(lineEnd)}`, start: lineStart + prefix.length, end: lineStart + block.length }
}
export function applyMarkdown(text: string, start: number, end: number, action: MarkdownAction): TextEdit {
switch (action) {
case 'bold':
return wrap(text, start, end, '**', 'testo in grassetto')
case 'italic':
return wrap(text, start, end, '_', 'testo in corsivo')
case 'h2':
return prefixLines(text, start, end, '## ', true)
case 'h3':
return prefixLines(text, start, end, '### ', true)
case 'list':
return prefixLines(text, start, end, '- ', false)
case 'quote':
return prefixLines(text, start, end, '> ', false)
case 'link': {
const label = text.slice(start, end) || 'testo del link'
const snippet = `[${label}](${LINK_PLACEHOLDER})`
const urlStart = start + label.length + 3
return { text: `${text.slice(0, start)}${snippet}${text.slice(end)}`, start: urlStart, end: urlStart + LINK_PLACEHOLDER.length }
}
}
}
/** Testo alternativo proposto per un'immagine caricata (selezionato: va sostituito con una descrizione vera). */
export const IMAGE_ALT_PLACEHOLDER = "Descrizione dell'immagine"
/**
* Inserisce un'immagine `![alt](url)` al posto della selezione, su una riga a sé (un'immagine in mezzo a una
* frase resterebbe dentro il paragrafo). Seleziona il testo alternativo.
*/
export function insertImage(text: string, start: number, end: number, url: string): TextEdit {
const before = text.slice(0, start)
const after = text.slice(end)
const lead = before === '' || before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n'
const trail = after === '' || after.startsWith('\n\n') ? '' : after.startsWith('\n') ? '\n' : '\n\n'
const altStart = before.length + lead.length + 2
const snippet = `${lead}![${IMAGE_ALT_PLACEHOLDER}](${url})${trail}`
return { text: `${before}${snippet}${after}`, start: altStart, end: altStart + IMAGE_ALT_PLACEHOLDER.length }
}
/** ISO → valore di `<input type="datetime-local">` nell'ora locale del browser ("2026-10-05T14:30"). */
export function toLocalInput(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/** Valore di `datetime-local` (ora locale) → ISO, o null se vuoto/non valido. */
export function fromLocalInput(value: string): string | null {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
@@ -0,0 +1,222 @@
import type { QuoteAdminDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, viewChild } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { Router, RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { DownloadService } from '../../core/download.service'
import { formatDateTime, formatDay, formatEuro } from '../../core/format'
import { ADMIN_QUOTE_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { ConfirmDialog } from '../../shared/confirm-dialog'
import { Empty, Loading, LoadError } from '../../shared/state'
import { adminTitle, fullName, isNotFound } from './admin-common'
import { multipart, uploadErrorMessage } from './upload'
import { QuoteForm } from './quote-form'
/**
* Preventivo: dati (modificabili solo in bozza), PDF allegato (caricamento/sostituzione, download) e invio al
* cliente con email. Il cliente lo vede nell'area clienti solo dopo l'invio.
*/
@Component({
selector: 'app-admin-quote-detail',
imports: [RouterLink, Badge, ConfirmDialog, QuoteForm, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/admin/preventivi" class="back-link">← Preventivi</a>
@if (res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento del preventivo…" />
} @else if (notFound()) {
<h1>Preventivo non trovato</h1>
<app-empty message="Il preventivo non esiste." />
} @else if (res.error()) {
<h1>Preventivo</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as q) {
<div class="page-head">
<h1>Preventivo {{ q.number }}</h1>
<app-badge [status]="labels[q.status]" />
</div>
@if (notice(); as n) {
<p class="alert" [class.alert--success]="n.ok" [class.alert--error]="!n.ok" role="status">{{ n.text }}</p>
}
<div class="split">
<section class="panel section" aria-labelledby="data-title">
<h2 id="data-title">{{ q.title }}</h2>
<dl class="facts">
<dt>Cliente</dt>
<dd>
<a [routerLink]="['/admin/utenti', q.userId]">{{ q.customer.companyName ?? name(q.customer) }}</a><br />
<span class="meta">{{ q.customer.email }}</span>
</dd>
@if (q.quoteRequestId) {
<dt>Richiesta</dt>
<dd><a [routerLink]="['/admin/richieste', q.quoteRequestId]">Apri la richiesta</a></dd>
}
<dt>Importo</dt>
<dd class="amount">{{ euro(q.amountCents) }} <span class="meta">IVA inclusa</span></dd>
<dt>Validità</dt>
<dd>{{ q.validUntil ? 'fino al ' + day(q.validUntil) : 'senza scadenza' }}</dd>
<dt>Creato</dt>
<dd>{{ dateTime(q.createdAt) }}</dd>
<dt>Inviato</dt>
<dd>{{ q.sentAt ? dateTime(q.sentAt) : 'non ancora' }}</dd>
</dl>
@if (q.status === 'draft') {
<h3>Modifica</h3>
<app-quote-form [quote]="q" submitLabel="Salva le modifiche" (saved)="updated($event, 'Modifiche salvate.')" />
}
</section>
<div>
<section class="panel section" aria-labelledby="pdf-title">
<h2 id="pdf-title">PDF</h2>
@if (q.pdfDocumentId) {
<p>PDF allegato.</p>
<button type="button" class="btn btn--outline btn--small" (click)="downloadPdf(q)" [disabled]="downloading()">
{{ downloading() ? 'Download…' : 'Scarica il PDF' }}
</button>
} @else {
<p>Nessun PDF allegato: il cliente vedrà solo titolo, importo e validità.</p>
}
@if (open()) {
<div class="form-field upload">
<label for="quote-pdf">{{ q.pdfDocumentId ? 'Sostituisci il PDF' : 'Carica il PDF' }}</label>
<input id="quote-pdf" type="file" accept="application/pdf,.pdf" (change)="uploadPdf(q, $event)" [disabled]="uploading()" />
@if (uploading()) {
<p class="meta" role="status">Caricamento…</p>
}
</div>
}
</section>
@if (open()) {
<section class="panel section" aria-labelledby="send-title">
<h2 id="send-title">{{ q.status === 'sent' ? 'Invia di nuovo' : 'Invio al cliente' }}</h2>
<p>
Il cliente riceve un'email con il link al preventivo e può accettarlo o rifiutarlo dall'area clienti.
@if (q.status === 'draft') {
Dopo l'invio i dati non sono più modificabili.
}
</p>
<button type="button" class="btn btn--primary" (click)="dialog()?.open()">
{{ q.status === 'sent' ? 'Invia di nuovo' : 'Invia al cliente' }}
</button>
</section>
}
</div>
</div>
<app-confirm-dialog
title="Inviare il preventivo?"
[message]="
'Il preventivo ' + q.number + ' di ' + euro(q.amountCents) + ' sarà inviato a ' + q.customer.email + '.' +
(q.pdfDocumentId ? '' : ' Attenzione: non c\\'è un PDF allegato.')
"
confirmLabel="Invia"
[busy]="sending()"
(confirmed)="send(q)"
/>
}
`,
styles: `
h3 {
margin-top: var(--space-5);
font-size: var(--font-size-lg);
}
.upload {
margin-top: var(--space-4);
}
`,
})
export default class AdminQuoteDetail {
private readonly api = inject(ApiService)
private readonly downloads = inject(DownloadService)
readonly id = input.required<string>()
protected readonly res = rxResource({
params: () => this.id(),
stream: ({ params }) => this.api.get<QuoteAdminDto>(`/admin/quotes/${encodeURIComponent(params)}`),
})
protected readonly notFound = computed(() => isNotFound(this.res.error()))
/** Bozza o inviato senza risposta: PDF e invio ancora possibili. */
protected readonly open = computed(() => {
const s = this.res.value()?.status
return s === 'draft' || s === 'sent'
})
protected readonly notice = signal<{ ok: boolean; text: string } | null>(
(inject(Router).currentNavigation()?.extras.state as { created?: boolean } | undefined)?.created
? { ok: true, text: 'Bozza creata: allega il PDF e inviala al cliente.' }
: null,
)
protected readonly uploading = signal(false)
protected readonly sending = signal(false)
protected readonly downloading = signal(false)
protected readonly dialog = viewChild(ConfirmDialog)
protected readonly labels = ADMIN_QUOTE_STATUS
protected readonly euro = formatEuro
protected readonly day = formatDay
protected readonly dateTime = formatDateTime
protected readonly name = fullName
constructor() {
const setTitle = adminTitle()
effect(() => {
const q = this.res.value()
setTitle(q ? `Preventivo ${q.number}` : 'Preventivo')
})
}
protected updated(quote: QuoteAdminDto, text: string): void {
this.res.set(quote)
this.notice.set({ ok: true, text })
}
protected async uploadPdf(quote: QuoteAdminDto, event: Event): Promise<void> {
const field = event.target as HTMLInputElement
const file = field.files?.[0]
if (!file) return
const body = multipart(file)
this.uploading.set(true)
this.notice.set(null)
try {
this.updated(await firstValueFrom(this.api.post<QuoteAdminDto>(`/admin/quotes/${quote.id}/pdf`, body)), 'PDF caricato.')
} catch (err) {
this.notice.set({ ok: false, text: uploadErrorMessage(toApiError(err)) })
} finally {
field.value = ''
this.uploading.set(false)
}
}
protected async send(quote: QuoteAdminDto): Promise<void> {
if (this.sending()) return
this.sending.set(true)
try {
this.updated(await firstValueFrom(this.api.post<QuoteAdminDto>(`/admin/quotes/${quote.id}/send`, null)), `Preventivo inviato a ${quote.customer.email}.`)
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
this.res.reload()
} finally {
this.sending.set(false)
this.dialog()?.close()
}
}
protected async downloadPdf(quote: QuoteAdminDto): Promise<void> {
if (!quote.pdfDocumentId) return
this.downloading.set(true)
try {
await this.downloads.download(`/documents/${quote.pdfDocumentId}/download`, `Preventivo ${quote.number}.pdf`)
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
} finally {
this.downloading.set(false)
}
}
}
@@ -0,0 +1,124 @@
import type { QuoteAdminDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, effect, inject, input, output, signal } from '@angular/core'
import { FormControl, FormGroup, ReactiveFormsModule, Validators, type AbstractControl, type ValidationErrors } from '@angular/forms'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { applyServerErrors } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
import { centsToInput, parseEuroToCents } from './admin-common'
/** Importo in euro valido (formato italiano). */
export function euroValidator(control: AbstractControl<string>): ValidationErrors | null {
if (!control.value?.trim()) return null
return parseEuroToCents(control.value) === null ? { euro: true } : null
}
/** Corpo comune di creazione e modifica: titolo, importo in centesimi, validità (null = senza scadenza). */
export function quoteBody(value: { title: string; amount: string; validUntil: string }) {
return {
title: value.title.trim(),
amountCents: parseEuroToCents(value.amount) ?? 0,
validUntil: value.validUntil || null,
}
}
/**
* Dati di un preventivo: crea una bozza (`POST /admin/quotes`, da una richiesta o per un utente) oppure
* modifica quella esistente (`PATCH /admin/quotes/:id`, solo in bozza). Emette il preventivo salvato.
*/
@Component({
selector: 'app-quote-form',
imports: [ReactiveFormsModule, FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form (submit)="$event.preventDefault(); save()" novalidate>
<div class="form-grid">
<app-form-field class="span-2" [control]="form.controls.title" label="Titolo" [maxlength]="200" [submitted]="submitted()"
hint="Es. Impianto fotovoltaico 6 kW con accumulo" />
<app-form-field
[control]="form.controls.amount"
label="Importo (€, IVA inclusa)"
inputmode="decimal"
hint="Es. 9.900 o 1.234,50"
[messages]="{ euro: 'Importo non valido' }"
[submitted]="submitted()"
/>
<div class="form-field">
<label [for]="dateId">Valido fino al <span class="optional">(facoltativo)</span></label>
<input [id]="dateId" type="date" [formControl]="form.controls.validUntil" />
@if (dateError(); as e) {
<p class="error">{{ e }}</p>
}
</div>
</div>
@if (error(); as e) {
<p class="alert alert--error" role="alert">{{ e }}</p>
}
<button type="submit" class="btn btn--primary" [disabled]="busy()">{{ busy() ? 'Salvataggio…' : submitLabel() }}</button>
</form>
`,
styles: `
.form-grid {
margin-bottom: var(--space-4);
}
`,
})
export class QuoteForm {
private readonly api = inject(ApiService)
/** Preventivo da modificare; se assente si crea una bozza per `quoteRequestId` o `userId`. */
readonly quote = input<QuoteAdminDto | null>(null)
readonly quoteRequestId = input<string | null>(null)
readonly userId = input<string | null>(null)
readonly submitLabel = input('Crea la bozza')
readonly saved = output<QuoteAdminDto>()
private static seq = 0
protected readonly dateId = `quote-valid-${++QuoteForm.seq}`
protected readonly form = new FormGroup({
title: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(3), Validators.maxLength(200)] }),
amount: new FormControl('', { nonNullable: true, validators: [Validators.required, euroValidator] }),
validUntil: new FormControl('', { nonNullable: true }),
})
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly error = signal<string | null>(null)
protected readonly dateError = signal<string | null>(null)
constructor() {
effect(() => {
const q = this.quote()
if (q) this.form.setValue({ title: q.title, amount: centsToInput(q.amountCents), validUntil: q.validUntil ?? '' })
})
}
protected async save(): Promise<void> {
this.submitted.set(true)
this.error.set(null)
this.dateError.set(null)
if (this.form.invalid || this.busy()) return
const body = quoteBody(this.form.getRawValue())
const existing = this.quote()
this.busy.set(true)
try {
const saved = existing
? await firstValueFrom(this.api.patch<QuoteAdminDto>(`/admin/quotes/${existing.id}`, body))
: await firstValueFrom(
this.api.post<QuoteAdminDto>('/admin/quotes', {
...body,
...(this.quoteRequestId() ? { quoteRequestId: this.quoteRequestId() } : {}),
...(this.userId() ? { userId: this.userId() } : {}),
}),
)
this.submitted.set(false)
if (!existing) this.form.reset()
this.saved.emit(saved)
} catch (err) {
const e = toApiError(err)
const onDate = e.details.find((d) => d.path === 'body.validUntil')
if (onDate) this.dateError.set(onDate.message)
if (!applyServerErrors(this.form, e, { amountCents: 'amount' }) && !onDate) this.error.set(e.message)
} finally {
this.busy.set(false)
}
}
}
@@ -0,0 +1,327 @@
import type {
ConfiguratorDefinition,
NearbyInstallerDto,
QuoteAdminDto,
QuoteRequestAdminDto,
} from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { Router, RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiError, ApiService, toApiError } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { formatDateTime, formatEuro } from '../../core/format'
import { ADMIN_QUOTE_STATUS, optionsOf, QUOTE_REQUEST_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { Empty, Loading, LoadError, loadErrorMessage } from '../../shared/state'
import { adminTitle, isNotFound } from './admin-common'
import { answerRows, estimateRows, readConfiguratorInput, readConfiguratorResult } from './configurator-summary'
import { QuoteForm } from './quote-form'
import { StatusSelect } from './status-select'
type RequestDetail = QuoteRequestAdminDto & { quotes: QuoteAdminDto[] }
type NearbyResult = { province: string; point: { lat: number; lng: number }; installers: NearbyInstallerDto[] }
/**
* Dettaglio di una richiesta di preventivo: contatti, risposte del configuratore (etichette dalla definizione
* attuale), risultato calcolato, installatori che coprono la provincia, preventivi collegati e creazione di
* una nuova bozza (si completa con PDF e invio nella pagina del preventivo).
*/
@Component({
selector: 'app-admin-quote-request-detail',
imports: [RouterLink, Badge, QuoteForm, StatusSelect, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/admin/richieste" class="back-link">← Richieste di preventivo</a>
@if (res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento della richiesta…" />
} @else if (notFound()) {
<h1>Richiesta non trovata</h1>
<app-empty message="La richiesta non esiste o è stata cancellata." />
} @else if (res.error()) {
<h1>Richiesta di preventivo</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as r) {
<div class="page-head">
<h1>{{ r.name }}</h1>
<app-badge [status]="labels[r.status]" />
</div>
<div class="split">
<div>
<section class="panel section" aria-labelledby="contact-title">
<h2 id="contact-title">Contatti</h2>
<dl class="facts">
<dt>Email</dt>
<dd><a [href]="'mailto:' + r.email">{{ r.email }}</a></dd>
<dt>Telefono</dt>
<dd>
@if (r.phone) {
<a [href]="'tel:' + r.phone">{{ r.phone }}</a>
} @else {
—
}
</dd>
<dt>Provincia</dt>
<dd>{{ r.province ?? '—' }}</dd>
<dt>Ricevuta</dt>
<dd>{{ dateTime(r.createdAt) }}</dd>
<dt>Account</dt>
<dd>
@if (r.userId) {
<a [routerLink]="['/admin/utenti', r.userId]">Apri l'utente</a>
} @else {
nessuno (richiesta anonima)
}
</dd>
</dl>
@if (input().message) {
<h3>Messaggio</h3>
<p class="pre-line">{{ input().message }}</p>
}
</section>
@if (result(); as out) {
<section class="panel section" aria-labelledby="result-title">
<h2 id="result-title">Risultato del configuratore</h2>
<p><strong>{{ out.solution.title }}</strong></p>
<p>{{ out.solution.description }}</p>
@if (estimates().length) {
<dl class="facts">
@for (row of estimates(); track row.label) {
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
}
</dl>
}
@if (out.warnings.length) {
<ul class="warnings">
@for (w of out.warnings; track w) {
<li>{{ w }}</li>
}
</ul>
}
@if (out.products.length) {
<h3>Prodotti suggeriti</h3>
<ul>
@for (p of out.products; track p.handle) {
<li>
<a [href]="siteShop(p.handle)" target="_blank" rel="noopener">{{ p.handle }}</a> × {{ p.quantity }}
@if (p.note) {
<span class="meta">— {{ p.note }}</span>
}
</li>
}
</ul>
}
<details>
<summary>Come è stato calcolato</summary>
<dl class="facts">
@for (d of out.details; track d.label) {
<dt>{{ d.label }}</dt>
<dd>{{ d.value }}</dd>
}
</dl>
<p class="meta">Regole versione {{ out.rulesVersion }}</p>
</details>
</section>
}
<section class="panel section" aria-labelledby="answers-title">
<h2 id="answers-title">Risposte</h2>
@if (answers().length) {
<dl class="facts">
@for (a of answers(); track a.label) {
<dt>{{ a.label }}</dt>
<dd>{{ a.value }}</dd>
}
</dl>
@if (definition.error()) {
<p class="meta">Etichette non disponibili: sono mostrati gli identificativi delle domande.</p>
} @else if (versionChanged()) {
<p class="meta">Il configuratore è cambiato dopo l'invio (versione {{ input().definitionVersion }}): alcune etichette potrebbero non corrispondere.</p>
}
} @else {
<p>Nessuna risposta registrata.</p>
}
</section>
</div>
<div>
<section class="panel section" aria-labelledby="state-title">
<h2 id="state-title">Stato</h2>
<app-status-select label="Stato della richiesta" [value]="r.status" [options]="statusOptions" [busy]="busy()" (changed)="saveStatus(r, $event)" />
@if (statusMessage(); as m) {
<p class="meta" role="status">{{ m }}</p>
}
</section>
<section class="panel section" aria-labelledby="near-title">
<h2 id="near-title">Installatori in zona</h2>
@if (nearby.isLoading()) {
<app-loading label="Ricerca degli installatori…" />
} @else if (nearbyError(); as e) {
<p>{{ e }}</p>
} @else if (nearby.value(); as n) {
@if (n.installers.length) {
<p class="meta">Approvati il cui raggio copre il capoluogo di {{ n.province }}.</p>
<ul class="plain">
@for (i of n.installers; track i.userId) {
<li>
<a [routerLink]="['/admin/installatori', i.userId]">{{ i.businessName }}</a>
<span class="meta"> · {{ km(i.distanceKm) }} km (raggio {{ i.serviceRadiusKm }})</span><br />
<span class="meta">{{ i.phone ?? i.email }}</span>
</li>
}
</ul>
} @else {
<p>Nessun installatore approvato copre la provincia di {{ n.province }}.</p>
}
}
</section>
</div>
</div>
<section class="section" aria-labelledby="quotes-title">
<h2 id="quotes-title">Preventivi</h2>
@if (r.quotes.length) {
<ul class="item-list">
@for (q of r.quotes; track q.id) {
<li>
<a class="item" [routerLink]="['/admin/preventivi', q.id]">
<span class="item__main">
<span class="item__title">{{ q.number }} · {{ q.title }}</span>
<span class="meta">{{ euro(q.amountCents) }} · creato il {{ dateTime(q.createdAt) }}</span>
</span>
<app-badge [status]="quoteLabels[q.status]" />
</a>
</li>
}
</ul>
} @else {
<p>Nessun preventivo per questa richiesta.</p>
}
<div class="panel new-quote">
<h3>Nuovo preventivo</h3>
<p class="meta">
Si crea una bozza intestata all'account del richiedente (se non c'è, quello con la stessa email). Poi si
allega il PDF e si invia.
</p>
<app-quote-form [quoteRequestId]="r.id" (saved)="created($event)" />
</div>
</section>
}
`,
styles: `
h3 {
margin-top: var(--space-4);
font-size: var(--font-size-lg);
}
.warnings {
padding: var(--space-3) var(--space-3) var(--space-3) var(--space-6);
border-radius: var(--radius-md);
background: var(--color-accent-soft);
}
details {
margin-top: var(--space-4);
}
summary {
min-height: 44px;
cursor: pointer;
font-weight: var(--font-weight-semibold);
}
.plain {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.new-quote {
margin-top: var(--space-5);
h3 {
margin-top: 0;
}
}
`,
})
export default class AdminQuoteRequestDetail {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
readonly id = input.required<string>()
protected readonly res = rxResource({
params: () => this.id(),
stream: ({ params }) => this.api.get<RequestDetail>(`/admin/quote-requests/${encodeURIComponent(params)}`),
})
protected readonly definition = rxResource({
stream: () => this.api.get<ConfiguratorDefinition>('/configurator/definition', { anonymous: true }),
})
protected readonly nearby = rxResource({
params: () => (this.res.hasValue() ? this.id() : undefined),
stream: ({ params }) => this.api.get<NearbyResult>(`/admin/quote-requests/${encodeURIComponent(params)}/installers`),
})
protected readonly notFound = computed(() => isNotFound(this.res.error()))
protected readonly input = computed(() => readConfiguratorInput(this.res.value()?.configuratorInput))
protected readonly result = computed(() => readConfiguratorResult(this.res.value()?.configuratorOutput))
protected readonly estimates = computed(() => {
const r = this.result()
return r ? estimateRows(r) : []
})
protected readonly answers = computed(() =>
answerRows(this.input().answers ?? {}, this.definition.hasValue() ? this.definition.value() : undefined),
)
protected readonly versionChanged = computed(() => {
const v = this.input().definitionVersion
return this.definition.hasValue() && v !== undefined && v !== this.definition.value().version
})
protected readonly nearbyError = computed(() => {
const err = this.nearby.error()
if (!err) return null
return err instanceof ApiError && err.code === 'PROVINCE_MISSING'
? 'La richiesta non indica una provincia: impossibile cercare installatori in zona.'
: loadErrorMessage(err)
})
protected readonly statusOptions = optionsOf(QUOTE_REQUEST_STATUS)
protected readonly busy = signal(false)
protected readonly statusMessage = signal<string | null>(null)
protected readonly labels = QUOTE_REQUEST_STATUS
protected readonly quoteLabels = ADMIN_QUOTE_STATUS
protected readonly dateTime = formatDateTime
protected readonly euro = formatEuro
protected readonly km = (n: number) => n.toLocaleString('it-IT')
private readonly siteUrl = inject(APP_CONFIG).siteUrl
protected readonly siteShop = (handle: string) => `${this.siteUrl}/shop/${encodeURIComponent(handle)}`
constructor() {
const setTitle = adminTitle()
effect(() => {
const r = this.res.value()
setTitle(r ? `Richiesta di ${r.name}` : 'Richiesta di preventivo')
})
}
protected async saveStatus(r: RequestDetail, status: string): Promise<void> {
this.busy.set(true)
this.statusMessage.set(null)
try {
const updated = await firstValueFrom(this.api.patch<QuoteRequestAdminDto>(`/admin/quote-requests/${r.id}`, { status }))
this.res.set({ ...r, ...updated })
this.statusMessage.set('Stato aggiornato.')
} catch (err) {
this.statusMessage.set(toApiError(err).message)
} finally {
this.busy.set(false)
}
}
protected created(quote: QuoteAdminDto): void {
void this.router.navigate(['/admin/preventivi', quote.id], { state: { created: true } })
}
}
@@ -0,0 +1,97 @@
import type { Paginated, QuoteRequestAdminDto, QuoteRequestStatus } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { formatDateTime } from '../../core/format'
import { QUOTE_REQUEST_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
import { ADMIN_PAGE_SIZE, adminTitle, pageParams } from './admin-common'
import { readConfiguratorResult } from './configurator-summary'
const STATUSES = Object.keys(QUOTE_REQUEST_STATUS) as QuoteRequestStatus[]
const FILTERS: FilterOption[] = [{ value: null, label: 'Tutte' }, ...STATUSES.map((s) => ({ value: s, label: QUOTE_REQUEST_STATUS[s].label }))]
/** Richieste di preventivo (dal configuratore): filtro per stato, ricerca per nome o email. */
@Component({
selector: 'app-admin-quote-requests',
imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Richieste di preventivo</h1>
<div class="toolbar">
<app-filter-pills param="stato" [options]="filters" [current]="status()" label="Filtra per stato" />
<app-search-box [value]="q()" label="Cerca per nome o email" />
</div>
@if (res.isLoading()) {
<app-loading label="Caricamento delle richieste…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th scope="col">Ricevuta</th>
<th scope="col">Richiedente</th>
<th scope="col">Soluzione</th>
<th scope="col">Provincia</th>
<th scope="col">Stato</th>
</tr>
</thead>
<tbody>
@for (r of data.items; track r.id) {
<tr>
<td>{{ dateTime(r.createdAt) }}</td>
<td>
<a [routerLink]="['/admin/richieste', r.id]">{{ r.name }}</a><br />
<span class="meta">{{ r.email }}</span>
</td>
<td>{{ solution(r) }}</td>
<td>{{ r.province ?? '—' }}</td>
<td><app-badge [status]="labels[r.status]" /></td>
</tr>
}
</tbody>
</table>
</div>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty message="Nessuna richiesta trovata." />
}
}
`,
})
export default class AdminQuoteRequests {
private readonly api = inject(ApiService)
readonly pagina = input<string>()
readonly stato = input<string>()
readonly q = input<string>()
protected readonly filters = FILTERS
protected readonly pageSize = ADMIN_PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly status = computed(() => (STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as QuoteRequestStatus) : null)
protected readonly res = rxResource({
params: () => ({ page: this.page(), status: this.status(), q: this.q() }),
stream: ({ params }) =>
this.api.get<Paginated<QuoteRequestAdminDto>>('/admin/quote-requests', {
query: { ...pageParams(params.page), status: params.status, q: params.q },
}),
})
protected readonly labels = QUOTE_REQUEST_STATUS
protected readonly dateTime = formatDateTime
protected solution(r: QuoteRequestAdminDto): string {
return readConfiguratorResult(r.configuratorOutput)?.solution.title ?? '—'
}
constructor() {
adminTitle()('Richieste di preventivo')
}
}
+103
View File
@@ -0,0 +1,103 @@
import type { Paginated, QuoteAdminDto, QuoteStatus } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { formatDate, formatDay, formatEuro } from '../../core/format'
import { ADMIN_QUOTE_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { type FilterOption, FilterPills } from '../../shared/list-filters'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
import { ADMIN_PAGE_SIZE, adminTitle, fullName, pageParams } from './admin-common'
const STATUSES = Object.keys(ADMIN_QUOTE_STATUS) as QuoteStatus[]
const FILTERS: FilterOption[] = [{ value: null, label: 'Tutti' }, ...STATUSES.map((s) => ({ value: s, label: ADMIN_QUOTE_STATUS[s].label }))]
/**
* Preventivi, dal più recente. I nuovi si creano da una richiesta di preventivo o dalla scheda di un utente.
* Il filtro "Scaduto" mostra quelli già segnati come scaduti in DB (succede quando il cliente prova a rispondere).
*/
@Component({
selector: 'app-admin-quotes',
imports: [RouterLink, Badge, FilterPills, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Preventivi</h1>
<p class="meta">Per un nuovo preventivo apri la richiesta del cliente o la sua scheda in <a routerLink="/admin/utenti">Utenti</a>.</p>
<div class="toolbar">
<app-filter-pills param="stato" [options]="filters" [current]="status()" label="Filtra per stato" />
</div>
@if (res.isLoading()) {
<app-loading label="Caricamento dei preventivi…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th scope="col">Numero</th>
<th scope="col">Titolo</th>
<th scope="col">Cliente</th>
<th scope="col" class="num">Importo</th>
<th scope="col">Validità</th>
<th scope="col">Stato</th>
</tr>
</thead>
<tbody>
@for (q of data.items; track q.id) {
<tr>
<td><a [routerLink]="['/admin/preventivi', q.id]">{{ q.number }}</a></td>
<td>{{ q.title }}</td>
<td>
{{ q.customer.companyName ?? name(q.customer) }}<br />
<span class="meta">{{ q.customer.email }}</span>
</td>
<td class="num">{{ euro(q.amountCents) }}</td>
<td>{{ q.validUntil ? day(q.validUntil) : '—' }}</td>
<td>
<app-badge [status]="labels[q.status]" />
@if (q.sentAt) {
<br /><span class="meta">inviato il {{ date(q.sentAt) }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty message="Nessun preventivo trovato." />
}
}
`,
})
export default class AdminQuotes {
private readonly api = inject(ApiService)
readonly pagina = input<string>()
readonly stato = input<string>()
protected readonly filters = FILTERS
protected readonly pageSize = ADMIN_PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly status = computed(() => ((STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as QuoteStatus) : null))
protected readonly res = rxResource({
params: () => ({ page: this.page(), status: this.status() }),
stream: ({ params }) =>
this.api.get<Paginated<QuoteAdminDto>>('/admin/quotes', { query: { ...pageParams(params.page), status: params.status } }),
})
protected readonly labels = ADMIN_QUOTE_STATUS
protected readonly euro = formatEuro
protected readonly day = formatDay
protected readonly date = formatDate
protected readonly name = fullName
constructor() {
adminTitle()('Preventivi')
}
}
@@ -0,0 +1,114 @@
import type { Paginated, ShowroomBookingAdminDto, ShowroomBookingStatus } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { formatDateTime } from '../../core/format'
import { BOOKING_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
import { ADMIN_PAGE_SIZE, adminTitle, pageParams } from './admin-common'
const STATUSES = Object.keys(BOOKING_STATUS) as ShowroomBookingStatus[]
const STATUS_FILTERS: FilterOption[] = [{ value: null, label: 'Tutte' }, ...STATUSES.map((s) => ({ value: s, label: BOOKING_STATUS[s].label }))]
const PERIOD_FILTERS: FilterOption[] = [
{ value: null, label: 'Da oggi' },
{ value: 'tutte', label: 'Anche passate' },
]
/** Mezzanotte di oggi (ora locale del browser) in ISO: le visite di oggi restano in elenco tutto il giorno. */
export function startOfToday(now = new Date()): string {
const d = new Date(now)
d.setHours(0, 0, 0, 0)
return d.toISOString()
}
/** Prenotazioni dello showroom in ordine di data della visita; di default da oggi in poi. */
@Component({
selector: 'app-admin-showroom',
imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Prenotazioni showroom</h1>
<div class="toolbar">
<div class="filters">
<app-filter-pills param="periodo" [options]="periodFilters" [current]="period()" label="Periodo" />
<app-filter-pills param="stato" [options]="statusFilters" [current]="status()" label="Filtra per stato" />
</div>
<app-search-box [value]="q()" label="Cerca per nome o email" />
</div>
@if (res.isLoading()) {
<app-loading label="Caricamento delle prenotazioni…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th scope="col">Visita</th>
<th scope="col">Visitatore</th>
<th scope="col" class="num">Persone</th>
<th scope="col">Stato</th>
</tr>
</thead>
<tbody>
@for (b of data.items; track b.id) {
<tr>
<td><a [routerLink]="['/admin/showroom', b.id]">{{ dateTime(b.requestedAt) }}</a></td>
<td>
{{ b.name }}<br />
<span class="meta">{{ b.email }}{{ b.phone ? ' · ' + b.phone : '' }}</span>
</td>
<td class="num">{{ b.people }}</td>
<td><app-badge [status]="labels[b.status]" /></td>
</tr>
}
</tbody>
</table>
</div>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty message="Nessuna prenotazione trovata." />
}
}
`,
styles: `
.filters {
display: grid;
gap: var(--space-2);
}
`,
})
export default class AdminShowroom {
private readonly api = inject(ApiService)
readonly pagina = input<string>()
readonly stato = input<string>()
readonly periodo = input<string>()
readonly q = input<string>()
protected readonly statusFilters = STATUS_FILTERS
protected readonly periodFilters = PERIOD_FILTERS
protected readonly pageSize = ADMIN_PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly status = computed(() => ((STATUSES as string[]).includes(this.stato() ?? '') ? (this.stato() as ShowroomBookingStatus) : null))
protected readonly period = computed(() => (this.periodo() === 'tutte' ? 'tutte' : null))
protected readonly res = rxResource({
params: () => ({ page: this.page(), status: this.status(), all: this.period() === 'tutte', q: this.q() }),
stream: ({ params }) =>
this.api.get<Paginated<ShowroomBookingAdminDto>>('/admin/showroom/bookings', {
query: { ...pageParams(params.page), status: params.status, q: params.q, from: params.all ? null : startOfToday() },
}),
})
protected readonly labels = BOOKING_STATUS
protected readonly dateTime = formatDateTime
constructor() {
adminTitle()('Prenotazioni showroom')
}
}
@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, effect, input, output } from '@angular/core'
import { FormControl, ReactiveFormsModule } from '@angular/forms'
let nextId = 0
/** Cambio di stato con un `<select>` e "Salva" (abilitato solo se lo stato scelto è diverso da quello attuale). */
@Component({
selector: 'app-status-select',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form class="inline-field" (submit)="$event.preventDefault(); save()">
<label [for]="id">{{ label() }}</label>
<select [id]="id" [formControl]="control">
@for (o of options(); track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
<button type="submit" class="btn btn--outline btn--small" [disabled]="busy() || control.value === value()">Salva</button>
</form>
`,
})
export class StatusSelect {
readonly value = input.required<string>()
readonly options = input.required<{ value: string; label: string }[]>()
readonly label = input('Stato')
readonly busy = input(false)
readonly changed = output<string>()
protected readonly id = `status-${++nextId}`
protected readonly control = new FormControl('', { nonNullable: true })
constructor() {
effect(() => this.control.setValue(this.value()))
}
protected save(): void {
if (this.control.value !== this.value()) this.changed.emit(this.control.value)
}
}
@@ -0,0 +1,228 @@
import type { TicketAdminDto, TicketDetailDto, TicketPriority, TicketStatus } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { formatDateTime } from '../../core/format'
import { ADMIN_TICKET_STATUS, optionsOf, TICKET_PRIORITY } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { FormField } from '../../shared/form-field'
import { Empty, Loading, LoadError } from '../../shared/state'
import { adminTitle, fullName, isNotFound } from './admin-common'
type TicketWithMessages = TicketAdminDto & Pick<TicketDetailDto, 'messages'>
/**
* Conversazione di un ticket lato staff: risposta (email al cliente) con lo stato che il ticket deve assumere
* (di default "in attesa del cliente"), cambio di stato e priorità senza risposta.
*/
@Component({
selector: 'app-admin-ticket-detail',
imports: [ReactiveFormsModule, RouterLink, Badge, FormField, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/admin/ticket" class="back-link">← Ticket</a>
@if (res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento del ticket…" />
} @else if (notFound()) {
<h1>Ticket non trovato</h1>
<app-empty message="Il ticket non esiste." />
} @else if (res.error()) {
<h1>Ticket</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as t) {
<div class="page-head">
<h1>{{ t.subject }}</h1>
<app-badge [status]="labels[t.status]" />
</div>
<p class="meta">
Da <a [routerLink]="['/admin/utenti', t.userId]">{{ name(t.customer) }}</a> ({{ t.customer.email }}) · aperto il
{{ dateTime(t.createdAt) }} · priorità {{ priorities[t.priority].toLowerCase() }}
</p>
@if (notice(); as n) {
<p class="alert" [class.alert--success]="n.ok" [class.alert--error]="!n.ok" role="status">{{ n.text }}</p>
}
<div class="split">
<div>
<ol class="thread" aria-label="Messaggi">
@for (m of t.messages; track m.id) {
<li [class.staff]="m.fromStaff">
<p class="who">
<strong>{{ m.fromStaff ? 'Assistenza' : name(t.customer) }}</strong>
<span class="meta">{{ dateTime(m.createdAt) }}</span>
</p>
<p class="pre-line">{{ m.body }}</p>
</li>
}
</ol>
<form class="panel" [formGroup]="reply" (ngSubmit)="send(t)" novalidate>
<h2>Rispondi</h2>
<app-form-field [control]="reply.controls.body" label="Risposta" [rows]="6" [maxlength]="10000" [submitted]="submitted()"
hint="Il cliente la riceve via email e la trova nell'area clienti." />
<div class="inline-field after">
<label for="reply-status">Dopo l'invio, stato</label>
<select id="reply-status" formControlName="status">
@for (o of statusOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<button type="submit" class="btn btn--primary" [disabled]="busy()">{{ busy() ? 'Invio…' : 'Invia la risposta' }}</button>
</form>
</div>
<section class="panel" aria-labelledby="manage-title">
<h2 id="manage-title">Gestione</h2>
<form [formGroup]="manage" (ngSubmit)="update(t)">
<div class="form-field">
<label for="ticket-status">Stato</label>
<select id="ticket-status" formControlName="status">
@for (o of statusOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="form-field">
<label for="ticket-priority">Priorità</label>
<select id="ticket-priority" formControlName="priority">
@for (o of priorityOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<button type="submit" class="btn btn--outline" [disabled]="busy() || unchanged(t)">Salva</button>
</form>
</section>
</div>
}
`,
styles: `
.thread {
display: grid;
gap: var(--space-3);
margin: 0 0 var(--space-5);
padding: 0;
list-style: none;
li {
padding: var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface);
}
li.staff {
border-color: var(--color-primary);
background: var(--color-primary-soft);
}
p:last-child {
margin: 0;
}
}
.who {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
h2 {
font-size: var(--font-size-xl);
}
.form-field,
app-form-field,
.after {
margin-bottom: var(--space-4);
}
`,
})
export default class AdminTicketDetail {
private readonly api = inject(ApiService)
readonly id = input.required<string>()
protected readonly res = rxResource({
params: () => this.id(),
stream: ({ params }) => this.api.get<TicketWithMessages>(`/admin/tickets/${encodeURIComponent(params)}`),
})
protected readonly notFound = computed(() => isNotFound(this.res.error()))
protected readonly reply = new FormGroup({
body: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(10000)] }),
status: new FormControl<TicketStatus>('waiting_customer', { nonNullable: true }),
})
protected readonly manage = new FormGroup({
status: new FormControl<TicketStatus>('open', { nonNullable: true }),
priority: new FormControl<TicketPriority>('normal', { nonNullable: true }),
})
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly notice = signal<{ ok: boolean; text: string } | null>(null)
protected readonly statusOptions = optionsOf(ADMIN_TICKET_STATUS)
protected readonly priorityOptions = optionsOf(TICKET_PRIORITY)
protected readonly labels = ADMIN_TICKET_STATUS
protected readonly priorities = TICKET_PRIORITY
protected readonly dateTime = formatDateTime
protected readonly name = fullName
constructor() {
const setTitle = adminTitle()
effect(() => {
const t = this.res.value()
setTitle(t ? `Ticket: ${t.subject}` : 'Ticket')
if (t) this.manage.setValue({ status: t.status, priority: t.priority })
})
}
protected unchanged(t: TicketWithMessages): boolean {
const v = this.manage.getRawValue()
return v.status === t.status && v.priority === t.priority
}
protected async send(t: TicketWithMessages): Promise<void> {
this.submitted.set(true)
this.notice.set(null)
const { body, status } = this.reply.getRawValue()
if (this.reply.invalid || body.trim() === '' || this.busy()) {
this.reply.controls.body.setErrors({ required: true })
return
}
this.busy.set(true)
try {
const updated = await firstValueFrom(this.api.post<TicketWithMessages>(`/admin/tickets/${t.id}/messages`, { body: body.trim(), status }))
this.res.set(updated)
this.reply.reset()
this.submitted.set(false)
this.notice.set({ ok: true, text: `Risposta inviata a ${t.customer.email}.` })
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
} finally {
this.busy.set(false)
}
}
protected async update(t: TicketWithMessages): Promise<void> {
if (this.unchanged(t)) return
const v = this.manage.getRawValue()
this.busy.set(true)
this.notice.set(null)
try {
const updated = await firstValueFrom(
this.api.patch<TicketWithMessages>(`/admin/tickets/${t.id}`, {
...(v.status !== t.status ? { status: v.status } : {}),
...(v.priority !== t.priority ? { priority: v.priority } : {}),
}),
)
this.res.set(updated)
this.notice.set({ ok: true, text: 'Ticket aggiornato.' })
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
} finally {
this.busy.set(false)
}
}
}
+116
View File
@@ -0,0 +1,116 @@
import type { Paginated, TicketAdminDto, TicketPriority, TicketStatus } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { formatDateTime } from '../../core/format'
import { ADMIN_TICKET_STATUS, TICKET_PRIORITY } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
import { ADMIN_PAGE_SIZE, adminTitle, fullName, pageParams } from './admin-common'
const STATUSES = Object.keys(ADMIN_TICKET_STATUS) as TicketStatus[]
const PRIORITIES = Object.keys(TICKET_PRIORITY) as TicketPriority[]
const STATUS_FILTERS: FilterOption[] = [{ value: null, label: 'Tutti' }, ...STATUSES.map((s) => ({ value: s, label: ADMIN_TICKET_STATUS[s].label }))]
const PRIORITY_FILTERS: FilterOption[] = [{ value: null, label: 'Ogni priorità' }, ...PRIORITIES.map((p) => ({ value: p, label: TICKET_PRIORITY[p] }))]
const pick = <T extends string>(values: readonly T[], value: string | undefined): T | null =>
(values as readonly string[]).includes(value ?? '') ? (value as T) : null
/** Ticket di assistenza, aggiornati più di recente in cima: filtri per stato e priorità, ricerca per oggetto o email. */
@Component({
selector: 'app-admin-tickets',
imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Ticket</h1>
<div class="toolbar">
<div class="filters">
<app-filter-pills param="stato" [options]="statusFilters" [current]="status()" label="Filtra per stato" />
<app-filter-pills param="priorita" [options]="priorityFilters" [current]="priority()" label="Filtra per priorità" />
</div>
<app-search-box [value]="q()" label="Cerca per oggetto o email" />
</div>
@if (res.isLoading()) {
<app-loading label="Caricamento dei ticket…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th scope="col">Oggetto</th>
<th scope="col">Cliente</th>
<th scope="col">Priorità</th>
<th scope="col">Aggiornato</th>
<th scope="col">Stato</th>
</tr>
</thead>
<tbody>
@for (t of data.items; track t.id) {
<tr>
<td><a [routerLink]="['/admin/ticket', t.id]">{{ t.subject }}</a></td>
<td>
{{ name(t.customer) }}<br />
<span class="meta">{{ t.customer.email }}</span>
</td>
<td [class.urgent]="t.priority === 'urgent' || t.priority === 'high'">{{ priorities[t.priority] }}</td>
<td>{{ dateTime(t.updatedAt) }}</td>
<td><app-badge [status]="labels[t.status]" /></td>
</tr>
}
</tbody>
</table>
</div>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty message="Nessun ticket trovato." />
}
}
`,
styles: `
.filters {
display: grid;
gap: var(--space-2);
}
.urgent {
font-weight: var(--font-weight-bold);
color: var(--color-danger);
}
`,
})
export default class AdminTickets {
private readonly api = inject(ApiService)
readonly pagina = input<string>()
readonly stato = input<string>()
readonly priorita = input<string>()
readonly q = input<string>()
protected readonly statusFilters = STATUS_FILTERS
protected readonly priorityFilters = PRIORITY_FILTERS
protected readonly pageSize = ADMIN_PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly status = computed(() => pick(STATUSES, this.stato()))
protected readonly priority = computed(() => pick(PRIORITIES, this.priorita()))
protected readonly res = rxResource({
params: () => ({ page: this.page(), status: this.status(), priority: this.priority(), q: this.q() }),
stream: ({ params }) =>
this.api.get<Paginated<TicketAdminDto>>('/admin/tickets', {
query: { ...pageParams(params.page), status: params.status, priority: params.priority, q: params.q },
}),
})
protected readonly labels = ADMIN_TICKET_STATUS
protected readonly priorities = TICKET_PRIORITY
protected readonly dateTime = formatDateTime
protected readonly name = fullName
constructor() {
adminTitle()('Ticket')
}
}
@@ -0,0 +1,25 @@
import type { ApiError } from '../../core/api.service'
/** Messaggio per gli errori di upload dell'API (tipo non ammesso, troppo grande, file mancante). */
export function uploadErrorMessage(error: ApiError): string {
switch (error.code) {
case 'UNSUPPORTED_FILE_TYPE':
return 'Tipo di file non ammesso, oppure il contenuto non corrisponde all\'estensione.'
case 'FILE_TOO_LARGE':
return 'Il file è troppo grande.'
case 'FILE_REQUIRED':
return 'Seleziona un file.'
default:
return error.message
}
}
/** FormData con il file e i campi testuali (multipart di `/admin/documents`, `/admin/uploads/image`…). */
export function multipart(file: File, fields: Record<string, string | null | undefined> = {}): FormData {
const body = new FormData()
for (const [key, value] of Object.entries(fields)) {
if (value !== null && value !== undefined && value !== '') body.append(key, value)
}
body.append('file', file, file.name)
return body
}
@@ -0,0 +1,231 @@
import type { Paginated, QuoteAdminDto, UserAdminDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, viewChild } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { Router, RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { AuthStore } from '../../core/auth.store'
import { formatDateTime, formatEuro } from '../../core/format'
import { accountStatus, ADMIN_QUOTE_STATUS, INSTALLER_STATUS } from '../../core/labels'
import { ROLE_LABELS } from '../../core/roles'
import { Badge } from '../../shared/badge'
import { ConfirmDialog } from '../../shared/confirm-dialog'
import { Empty, Loading, LoadError } from '../../shared/state'
import { adminTitle, fullName, isNotFound } from './admin-common'
import { QuoteForm } from './quote-form'
type ToggleResult = UserAdminDto & { medusaSynced: boolean | null }
/**
* Scheda utente: dati dell'account, disabilita/riabilita (chiude tutte le sessioni; per un installatore anche il
* listino riservato), preventivi e creazione di un preventivo intestato all'utente.
*/
@Component({
selector: 'app-admin-user-detail',
imports: [RouterLink, Badge, ConfirmDialog, QuoteForm, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/admin/utenti" class="back-link">← Utenti</a>
@if (res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento dell'utente…" />
} @else if (notFound()) {
<h1>Utente non trovato</h1>
<app-empty message="L'utente non esiste." />
} @else if (res.error()) {
<h1>Utente</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as u) {
<div class="page-head">
<h1>{{ name(u) }}</h1>
<app-badge [status]="state(u.disabledAt)" />
</div>
@if (notice(); as n) {
<p class="alert" [class.alert--success]="n.ok" [class.alert--error]="!n.ok" role="status">{{ n.text }}</p>
}
<div class="split">
<section class="panel section" aria-labelledby="account-title">
<h2 id="account-title">Account</h2>
<dl class="facts">
<dt>Email</dt>
<dd>
<a [href]="'mailto:' + u.email">{{ u.email }}</a>
<span class="meta"> · {{ u.emailVerified ? 'verificata' : 'non verificata' }}</span>
</dd>
<dt>Ruolo</dt>
<dd>{{ roles[u.role] }}</dd>
@if (u.companyName) {
<dt>Ragione sociale</dt>
<dd>{{ u.companyName }}</dd>
}
@if (u.vatNumber) {
<dt>P.IVA</dt>
<dd>{{ u.vatNumber }}</dd>
}
@if (u.installerStatus; as s) {
<dt>Profilo installatore</dt>
<dd>
<a [routerLink]="['/admin/installatori', u.id]">{{ installerLabels[s].label }}</a>
</dd>
}
<dt>Shop</dt>
<dd>{{ u.hasShopAccount ? 'cliente collegato in Medusa' : 'non ancora collegato' }}</dd>
<dt>Registrato</dt>
<dd>{{ dateTime(u.createdAt) }}</dd>
@if (u.disabledAt) {
<dt>Disabilitato il</dt>
<dd>{{ dateTime(u.disabledAt) }}</dd>
}
</dl>
<p class="links">
<a [routerLink]="'/admin/ticket'" [queryParams]="{ q: u.email }">Ticket di questo utente</a>
</p>
</section>
<section class="panel section" aria-labelledby="access-title">
<h2 id="access-title">Accesso</h2>
@if (isSelf(u)) {
<p>Questo è il tuo account.</p>
} @else if (u.disabledAt) {
<p>
L'utente non può accedere. Se ha chiesto la cancellazione dell'account, i dati vanno rimossi a mano
(vedi procedura): riabilitarlo gli restituisce l'accesso.
</p>
<button type="button" class="btn btn--primary" (click)="dialog()?.open()">Riabilita</button>
} @else {
<p>Disabilitando l'account l'utente esce da tutti i dispositivi e non può più accedere.</p>
<button type="button" class="btn btn--danger" (click)="dialog()?.open()">Disabilita</button>
}
</section>
</div>
@if (u.role !== 'admin') {
<section class="section" aria-labelledby="quotes-title">
<h2 id="quotes-title">Preventivi</h2>
@if (quotes.isLoading()) {
<app-loading />
} @else if (quotes.error()) {
<app-load-error [error]="quotes.error()" (retry)="quotes.reload()" />
} @else if (quotes.value()?.items?.length) {
<ul class="item-list">
@for (q of quotes.value()?.items; track q.id) {
<li>
<a class="item" [routerLink]="['/admin/preventivi', q.id]">
<span class="item__main">
<span class="item__title">{{ q.number }} · {{ q.title }}</span>
<span class="meta">{{ euro(q.amountCents) }}</span>
</span>
<app-badge [status]="quoteLabels[q.status]" />
</a>
</li>
}
</ul>
} @else {
<p>Nessun preventivo.</p>
}
<details class="panel new-quote">
<summary>Nuovo preventivo per {{ name(u) }}</summary>
<app-quote-form [userId]="u.id" (saved)="created($event)" />
</details>
</section>
}
<app-confirm-dialog
[title]="u.disabledAt ? 'Riabilitare l\\'account?' : 'Disabilitare l\\'account?'"
[message]="
u.disabledAt
? u.email + ' potrà di nuovo accedere.'
: u.email + ' verrà disconnesso da tutti i dispositivi e non potrà più accedere finché non lo riabiliti.'
"
[confirmLabel]="u.disabledAt ? 'Riabilita' : 'Disabilita'"
[danger]="!u.disabledAt"
[busy]="busy()"
(confirmed)="toggle(u)"
/>
}
`,
styles: `
.links {
margin: var(--space-4) 0 0;
}
.new-quote {
margin-top: var(--space-4);
summary {
min-height: 44px;
cursor: pointer;
font-weight: var(--font-weight-semibold);
}
app-quote-form {
display: block;
margin-top: var(--space-4);
}
}
`,
})
export default class AdminUserDetail {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly auth = inject(AuthStore)
readonly id = input.required<string>()
protected readonly res = rxResource({
params: () => this.id(),
stream: ({ params }) => this.api.get<UserAdminDto>(`/admin/users/${encodeURIComponent(params)}`),
})
protected readonly quotes = rxResource({
params: () => (this.res.value()?.role === 'admin' || !this.res.hasValue() ? undefined : this.id()),
stream: ({ params }) => this.api.get<Paginated<QuoteAdminDto>>('/admin/quotes', { query: { userId: params, limit: 50 } }),
})
protected readonly notFound = computed(() => isNotFound(this.res.error()))
protected readonly busy = signal(false)
protected readonly notice = signal<{ ok: boolean; text: string } | null>(null)
protected readonly dialog = viewChild(ConfirmDialog)
protected readonly roles = ROLE_LABELS
protected readonly state = accountStatus
protected readonly installerLabels = INSTALLER_STATUS
protected readonly quoteLabels = ADMIN_QUOTE_STATUS
protected readonly dateTime = formatDateTime
protected readonly euro = formatEuro
protected readonly name = fullName
constructor() {
const setTitle = adminTitle()
effect(() => {
const u = this.res.value()
setTitle(u ? fullName(u) : 'Utente')
})
}
protected isSelf(u: UserAdminDto): boolean {
return this.auth.user()?.id === u.id
}
protected async toggle(u: UserAdminDto): Promise<void> {
if (this.busy()) return
const action = u.disabledAt ? 'enable' : 'disable'
this.busy.set(true)
this.notice.set(null)
try {
const { medusaSynced, ...user } = await firstValueFrom(this.api.post<ToggleResult>(`/admin/users/${u.id}/${action}`, null))
this.res.set(user)
const text = action === 'disable' ? 'Account disabilitato: sessioni chiuse.' : 'Account riabilitato.'
this.notice.set(
medusaSynced === false
? { ok: false, text: `${text} Lo shop non ha risposto: il listino riservato va riallineato dalla scheda installatore.` }
: { ok: true, text },
)
} catch (err) {
this.notice.set({ ok: false, text: toApiError(err).message })
} finally {
this.busy.set(false)
this.dialog()?.close()
}
}
protected created(quote: QuoteAdminDto): void {
void this.router.navigate(['/admin/preventivi', quote.id], { state: { created: true } })
}
}
+120
View File
@@ -0,0 +1,120 @@
import type { Paginated, UserAdminDto, UserRole } from '@iti/shared'
import { USER_ROLES } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { formatDate } from '../../core/format'
import { accountStatus } from '../../core/labels'
import { ROLE_LABELS } from '../../core/roles'
import { Badge } from '../../shared/badge'
import { type FilterOption, FilterPills, SearchBox } from '../../shared/list-filters'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
import { ADMIN_PAGE_SIZE, adminTitle, fullName, pageParams } from './admin-common'
const ROLE_FILTERS: FilterOption[] = [{ value: null, label: 'Tutti i ruoli' }, ...USER_ROLES.map((r) => ({ value: r, label: ROLE_LABELS[r] }))]
const STATUS_FILTERS: FilterOption[] = [
{ value: null, label: 'Tutti' },
{ value: 'active', label: 'Attivi' },
{ value: 'disabled', label: 'Disabilitati' },
]
/** Utenti: ricerca per email, nome o ragione sociale; filtri per ruolo e stato dell'account. */
@Component({
selector: 'app-admin-users',
imports: [RouterLink, Badge, FilterPills, SearchBox, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Utenti</h1>
<div class="toolbar">
<div class="filters">
<app-filter-pills param="ruolo" [options]="roleFilters" [current]="role()" label="Filtra per ruolo" />
<app-filter-pills param="stato" [options]="statusFilters" [current]="status()" label="Filtra per stato" />
</div>
<app-search-box [value]="q()" label="Cerca per email, nome o azienda" />
</div>
@if (res.isLoading()) {
<app-loading label="Caricamento degli utenti…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th scope="col">Nome</th>
<th scope="col">Email</th>
<th scope="col">Ruolo</th>
<th scope="col">Registrato</th>
<th scope="col">Stato</th>
</tr>
</thead>
<tbody>
@for (u of data.items; track u.id) {
<tr>
<td>
<a [routerLink]="['/admin/utenti', u.id]">{{ name(u) }}</a>
@if (u.companyName) {
<br /><span class="meta">{{ u.companyName }}</span>
}
</td>
<td>
{{ u.email }}
@if (!u.emailVerified) {
<br /><span class="meta">non verificata</span>
}
</td>
<td>{{ roles[u.role] }}</td>
<td>{{ date(u.createdAt) }}</td>
<td><app-badge [status]="state(u.disabledAt)" /></td>
</tr>
}
</tbody>
</table>
</div>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty message="Nessun utente trovato." />
}
}
`,
styles: `
.filters {
display: grid;
gap: var(--space-2);
}
`,
})
export default class AdminUsers {
private readonly api = inject(ApiService)
readonly pagina = input<string>()
readonly ruolo = input<string>()
readonly stato = input<string>()
readonly q = input<string>()
protected readonly roleFilters = ROLE_FILTERS
protected readonly statusFilters = STATUS_FILTERS
protected readonly pageSize = ADMIN_PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly role = computed(() => ((USER_ROLES as readonly string[]).includes(this.ruolo() ?? '') ? (this.ruolo() as UserRole) : null))
protected readonly status = computed(() => (this.stato() === 'active' || this.stato() === 'disabled' ? this.stato()! : null))
protected readonly res = rxResource({
params: () => ({ page: this.page(), role: this.role(), status: this.status(), q: this.q() }),
stream: ({ params }) =>
this.api.get<Paginated<UserAdminDto>>('/admin/users', {
query: { ...pageParams(params.page), role: params.role, status: params.status, q: params.q },
}),
})
protected readonly roles = ROLE_LABELS
protected readonly state = accountStatus
protected readonly date = formatDate
protected readonly name = fullName
constructor() {
adminTitle()('Utenti')
}
}
@@ -0,0 +1,30 @@
import { DestroyRef, inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import type { AbstractControl, FormControl, ValidationErrors } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
/** Lunghezza minima della password (come l'API). */
export const PASSWORD_MIN = 10
/**
* Campo "ripeti la password": errore `mismatch` se diverso da `password`, ricontrollato quando cambia
* la password. Da chiamare nel costruttore.
*/
export function mustMatch(password: FormControl<string>, confirm: FormControl<string>): void {
confirm.addValidators((c: AbstractControl): ValidationErrors | null => (c.value === password.value ? null : { mismatch: true }))
password.valueChanges.pipe(takeUntilDestroyed(inject(DestroyRef))).subscribe(() => confirm.updateValueAndValidity())
}
/**
* Legge `?token=` dei link delle email (reset password, verifica email) e lo toglie subito dall'indirizzo
* (`replaceUrl`): non resta nella cronologia, nei preferiti o negli screenshot. Da chiamare nel costruttore.
*/
export function takeTokenFromUrl(): string | null {
const route = inject(ActivatedRoute)
const router = inject(Router)
const token = route.snapshot.queryParamMap.get('token')
if (token !== null) {
void router.navigate([], { relativeTo: route, queryParams: { token: null }, queryParamsHandling: 'merge', replaceUrl: true })
}
return token && token.length >= 20 && token.length <= 200 ? token : null
}
@@ -0,0 +1,76 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { authErrorMessage } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
/** Richiesta del link per reimpostare la password. La risposta è sempre la stessa (anti-enumerazione). */
@Component({
selector: 'app-forgot-password',
imports: [ReactiveFormsModule, RouterLink, FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Password dimenticata</h1>
@if (done(); as message) {
<p class="alert alert--success" role="status">{{ message }}</p>
<p>Il link vale un'ora. Se non trovi l'email, controlla anche nella posta indesiderata.</p>
<a routerLink="/login" class="btn btn--primary">Torna al login</a>
} @else {
<p>Inserisci l'email del tuo account: ti invieremo un link per scegliere una nuova password.</p>
@if (error(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<app-form-field [control]="form.controls.email" label="Email" type="email" autocomplete="email" [submitted]="submitted()" />
<button type="submit" class="btn btn--primary btn--block" [disabled]="busy()">
{{ busy() ? 'Invio in corso…' : 'Invia il link' }}
</button>
</form>
<p class="back"><a routerLink="/login">Torna al login</a></p>
}
`,
styles: `
app-form-field {
margin-bottom: var(--space-5);
}
.back {
margin: var(--space-5) 0 0;
text-align: center;
}
`,
})
export default class ForgotPassword {
private readonly api = inject(ApiService)
protected readonly form = new FormGroup({
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
})
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly error = signal<string | null>(null)
protected readonly done = signal<string | null>(null)
constructor() {
inject(Title).setTitle('Password dimenticata | Area riservata ITI TECHNOLOGY')
}
protected async submit(): Promise<void> {
this.submitted.set(true)
this.error.set(null)
if (this.form.invalid || this.busy()) return
this.busy.set(true)
try {
const res = await firstValueFrom(
this.api.post<{ message: string }>('/auth/forgot-password', { email: this.form.getRawValue().email.trim() }, { anonymous: true }),
)
this.done.set(res.message)
} catch (err) {
this.error.set(authErrorMessage(toApiError(err)))
} finally {
this.busy.set(false)
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { ApiError, toApiError } from '../../core/api.service'
import { AuthStore } from '../../core/auth.store'
import { authErrorMessage } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
/**
* Login. `?redirect=` (validato in core/redirect.ts) indica dove tornare: una pagina dell'area riservata
* o del sito pubblico (es. `/checkout`). `?scaduta=1` = sessione persa durante l'uso;
* `?account=disattivato` = account appena disattivato dall'utente.
*/
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule, RouterLink, FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Accedi</h1>
@if (account() === 'disattivato') {
<p class="alert alert--success" role="status">
Il tuo account è stato disattivato e la richiesta di cancellazione è stata inviata. Grazie per averci scelto.
</p>
}
@if (scaduta()) {
<p class="alert" role="status">La sessione è scaduta: accedi di nuovo per continuare.</p>
}
@if (error(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<div class="form-grid single">
<app-form-field [control]="form.controls.email" label="Email" type="email" autocomplete="email" [submitted]="submitted()" />
<app-form-field
[control]="form.controls.password"
label="Password"
type="password"
autocomplete="current-password"
[submitted]="submitted()"
/>
</div>
<p class="forgot"><a routerLink="/forgot-password">Password dimenticata?</a></p>
<button type="submit" class="btn btn--primary btn--block" [disabled]="busy()">
{{ busy() ? 'Accesso in corso…' : 'Accedi' }}
</button>
</form>
<p class="switch">
Non hai un account?
<a routerLink="/registrazione" [queryParams]="{ redirect: redirect() || null }">Registrati</a>
</p>
`,
styles: `
.single {
grid-template-columns: 1fr;
}
.forgot {
margin: var(--space-2) 0 var(--space-5);
text-align: right;
}
.switch {
margin: var(--space-5) 0 0;
text-align: center;
}
`,
})
export default class Login {
private readonly auth = inject(AuthStore)
readonly redirect = input<string>()
readonly scaduta = input<string>()
/** `disattivato` dopo la richiesta di cancellazione dell'account. */
readonly account = input<string>()
protected readonly form = new FormGroup({
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
password: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
})
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly error = signal<string | null>(null)
constructor() {
inject(Title).setTitle('Accedi | Area riservata ITI TECHNOLOGY')
}
protected async submit(): Promise<void> {
this.submitted.set(true)
this.error.set(null)
if (this.form.invalid || this.busy()) return
this.busy.set(true)
try {
const { email, password } = this.form.getRawValue()
await this.auth.login(email.trim(), password)
await this.auth.navigateAfterLogin(this.redirect() ?? null)
} catch (err) {
const error = err instanceof ApiError ? err : toApiError(err)
this.error.set(error.code === 'INVALID_CREDENTIALS' ? 'Email o password non corretti.' : authErrorMessage(error))
this.form.controls.password.reset()
} finally {
this.busy.set(false)
}
}
}
@@ -0,0 +1,126 @@
<h1>Crea un account</h1>
@if (done(); as message) {
<p class="alert alert--success" role="status">{{ message }}</p>
@if (accountType() === 'installer') {
<p>
Dopo la conferma dell'email potrai accedere e completare il profilo aziendale. Il listino riservato e la
documentazione tecnica saranno disponibili quando avremo approvato la tua richiesta.
</p>
}
<a routerLink="/login" [queryParams]="{ redirect: redirect() || null }" class="btn btn--primary">Vai al login</a>
} @else {
@if (error(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<fieldset>
<legend>Tipo di account</legend>
<div class="types">
@for (type of types; track type.value) {
<label class="check type">
<input type="radio" formControlName="accountType" [value]="type.value" name="accountType" />
<span>
<strong>{{ type.label }}</strong>
<small>{{ type.hint }}</small>
</span>
</label>
}
</div>
</fieldset>
<fieldset>
<legend>I tuoi dati</legend>
<div class="form-grid">
<app-form-field [control]="form.controls.firstName" label="Nome" autocomplete="given-name" [submitted]="submitted()" />
<app-form-field [control]="form.controls.lastName" label="Cognome" autocomplete="family-name" [submitted]="submitted()" />
@if (accountType() !== 'customer') {
<app-form-field
class="span-2"
[control]="form.controls.companyName"
label="Ragione sociale"
autocomplete="organization"
[submitted]="submitted()"
/>
<app-form-field
[control]="form.controls.vatNumber"
label="Partita IVA"
[required]="accountType() === 'company'"
[submitted]="submitted()"
[messages]="{ minlength: 'Partita IVA troppo corta' }"
/>
}
<app-form-field
[control]="form.controls.phone"
label="Telefono"
type="tel"
autocomplete="tel"
inputmode="tel"
[required]="false"
[submitted]="submitted()"
/>
</div>
</fieldset>
<fieldset>
<legend>Accesso</legend>
<div class="form-grid">
<app-form-field
class="span-2"
[control]="form.controls.email"
label="Email"
type="email"
autocomplete="email"
[submitted]="submitted()"
/>
<app-form-field
[control]="form.controls.password"
label="Password"
type="password"
autocomplete="new-password"
hint="Almeno 10 caratteri."
[submitted]="submitted()"
/>
<app-form-field
[control]="form.controls.confirm"
label="Ripeti la password"
type="password"
autocomplete="new-password"
[submitted]="submitted()"
[messages]="{ mismatch: 'Le due password non coincidono' }"
/>
</div>
</fieldset>
<div class="consents">
<label class="check">
<input
type="checkbox"
formControlName="acceptPrivacy"
[attr.aria-invalid]="submitted() && form.controls.acceptPrivacy.invalid ? 'true' : null"
aria-describedby="privacy-error"
/>
<span>
Ho letto l'<a [href]="siteUrl + '/privacy'" target="_blank" rel="noopener">informativa privacy</a> e acconsento
al trattamento dei dati per gestire il mio account.
</span>
</label>
@if (submitted() && form.controls.acceptPrivacy.invalid) {
<p class="field-error" id="privacy-error">Il consenso è necessario per creare l'account.</p>
}
<label class="check">
<input type="checkbox" formControlName="acceptMarketing" />
<span>Voglio ricevere novità e offerte via email (facoltativo, revocabile in qualsiasi momento).</span>
</label>
</div>
<button type="submit" class="btn btn--primary btn--block" [disabled]="busy()">
{{ busy() ? 'Registrazione in corso…' : 'Crea l’account' }}
</button>
</form>
<p class="switch">
Hai già un account? <a routerLink="/login" [queryParams]="{ redirect: redirect() || null }">Accedi</a>
</p>
}
+182
View File
@@ -0,0 +1,182 @@
import { ChangeDetectionStrategy, Component, effect, inject, input, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { applyServerErrors, authErrorMessage } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
import { mustMatch, PASSWORD_MIN } from './auth-helpers'
export type AccountType = 'customer' | 'company' | 'installer'
export const ACCOUNT_TYPES: { value: AccountType; label: string; hint: string }[] = [
{ value: 'customer', label: 'Privato', hint: 'Acquisti, preventivi e assistenza per la tua casa.' },
{ value: 'company', label: 'Azienda', hint: 'Fatturazione aziendale: servono ragione sociale e partita IVA.' },
{
value: 'installer',
label: 'Installatore',
hint: 'Listino riservato e documentazione tecnica, dopo l’approvazione del profilo da parte nostra.',
},
]
/** Corpo di `POST /auth/register`: solo i campi pertinenti al tipo di account, niente stringhe vuote. */
export function registerBody(value: {
accountType: AccountType
firstName: string
lastName: string
companyName: string
vatNumber: string
phone: string
email: string
password: string
acceptMarketing: boolean
}): Record<string, unknown> {
const optional = (v: string) => (v.trim() === '' ? undefined : v.trim())
const business = value.accountType !== 'customer'
return {
accountType: value.accountType,
firstName: value.firstName.trim(),
lastName: value.lastName.trim(),
email: value.email.trim(),
password: value.password,
companyName: business ? optional(value.companyName) : undefined,
vatNumber: business ? optional(value.vatNumber) : undefined,
phone: optional(value.phone),
acceptPrivacy: true,
acceptMarketing: value.acceptMarketing,
}
}
/**
* Registrazione: privato (customer), azienda (company: ragione sociale e P.IVA obbligatorie) o installatore
* (ragione sociale obbligatoria; il profilo resta "in attesa" finché un admin non lo approva).
* L'API risponde sempre 202 con lo stesso messaggio (anche se l'email esiste già): niente login automatico.
* `?email=` precompila l'indirizzo (link "Crea un account" dalla conferma d'ordine del sito).
*/
@Component({
selector: 'app-register',
imports: [ReactiveFormsModule, RouterLink, FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './register.html',
styles: `
.types {
display: grid;
gap: var(--space-2);
}
.type {
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
&:has(input:checked) {
border-color: var(--color-primary);
background: var(--color-primary-soft);
}
&:has(input:focus-visible) {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
small {
display: block;
color: var(--color-ink-muted);
}
}
.consents {
margin-top: var(--space-5);
}
.switch {
margin: var(--space-5) 0 0;
text-align: center;
}
`,
})
export default class Register {
private readonly api = inject(ApiService)
protected readonly siteUrl = inject(APP_CONFIG).siteUrl
protected readonly types = ACCOUNT_TYPES
readonly email = input<string>()
readonly redirect = input<string>()
protected readonly form = new FormGroup({
accountType: new FormControl<AccountType>('customer', { nonNullable: true }),
firstName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }),
lastName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }),
companyName: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200)] }),
vatNumber: new FormControl('', { nonNullable: true, validators: [Validators.minLength(5), Validators.maxLength(20)] }),
phone: new FormControl('', { nonNullable: true, validators: [Validators.minLength(5), Validators.maxLength(30)] }),
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
password: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(PASSWORD_MIN), Validators.maxLength(128)],
}),
confirm: new FormControl('', { nonNullable: true }),
acceptPrivacy: new FormControl(false, { nonNullable: true, validators: [Validators.requiredTrue] }),
acceptMarketing: new FormControl(false, { nonNullable: true }),
})
protected readonly accountType = signal<AccountType>('customer')
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly error = signal<string | null>(null)
protected readonly done = signal<string | null>(null)
constructor() {
inject(Title).setTitle('Registrati | Area riservata ITI TECHNOLOGY')
const { controls } = this.form
mustMatch(controls.password, controls.confirm)
controls.accountType.valueChanges.pipe(takeUntilDestroyed()).subscribe((type) => this.applyAccountType(type))
effect(() => {
const email = this.email()
if (email && controls.email.value === '') controls.email.setValue(email)
})
}
/** Campi aziendali obbligatori secondo il tipo di account (stesse regole dell'API). */
private applyAccountType(type: AccountType): void {
this.accountType.set(type)
const { companyName, vatNumber } = this.form.controls
companyName.setValidators(type === 'customer' ? [Validators.maxLength(200)] : [Validators.required, Validators.maxLength(200)])
vatNumber.setValidators(
type === 'company'
? [Validators.required, Validators.minLength(5), Validators.maxLength(20)]
: [Validators.minLength(5), Validators.maxLength(20)],
)
companyName.updateValueAndValidity()
vatNumber.updateValueAndValidity()
}
protected async submit(): Promise<void> {
this.submitted.set(true)
this.error.set(null)
if (this.form.invalid || this.busy()) {
this.focusFirstInvalid()
return
}
this.busy.set(true)
try {
const res = await firstValueFrom(
this.api.post<{ message: string }>('/auth/register', registerBody(this.form.getRawValue()), { anonymous: true }),
)
this.done.set(res.message)
} catch (err) {
const error = toApiError(err)
if (error.code === 'VALIDATION_ERROR' && applyServerErrors(this.form, error)) {
this.error.set('Controlla i campi evidenziati.')
this.focusFirstInvalid()
} else {
this.error.set(authErrorMessage(error))
}
} finally {
this.busy.set(false)
}
}
private focusFirstInvalid(): void {
queueMicrotask(() => document.querySelector<HTMLElement>('app-register [aria-invalid="true"]')?.focus())
}
}
@@ -0,0 +1,109 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { authErrorMessage } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
import { mustMatch, PASSWORD_MIN, takeTokenFromUrl } from './auth-helpers'
/**
* Nuova password dal link dell'email (`/reset-password?token=…`, 1 ora, monouso). Il token viene tolto
* dall'indirizzo appena letto. Il reset chiude tutte le sessioni dell'utente: dopo si accede di nuovo.
*/
@Component({
selector: 'app-reset-password',
imports: [ReactiveFormsModule, RouterLink, FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Nuova password</h1>
@if (!token) {
<p class="alert alert--error" role="alert">Il link non è completo. Aprilo di nuovo dall'email o richiedine uno nuovo.</p>
<a routerLink="/forgot-password" class="btn btn--primary">Richiedi un nuovo link</a>
} @else if (done()) {
<p class="alert alert--success" role="status">Password aggiornata. Per sicurezza abbiamo chiuso tutte le sessioni aperte.</p>
<a routerLink="/login" class="btn btn--primary">Accedi con la nuova password</a>
} @else if (invalid()) {
<p class="alert alert--error" role="alert">Il link non è valido o è scaduto (vale un'ora e si può usare una sola volta).</p>
<a routerLink="/forgot-password" class="btn btn--primary">Richiedi un nuovo link</a>
} @else {
@if (error(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<div class="form-grid single">
<app-form-field
[control]="form.controls.password"
label="Nuova password"
type="password"
autocomplete="new-password"
hint="Almeno 10 caratteri."
[submitted]="submitted()"
/>
<app-form-field
[control]="form.controls.confirm"
label="Ripeti la password"
type="password"
autocomplete="new-password"
[submitted]="submitted()"
[messages]="{ mismatch: 'Le due password non coincidono' }"
/>
</div>
<button type="submit" class="btn btn--primary btn--block" [disabled]="busy()">
{{ busy() ? 'Salvataggio…' : 'Salva la password' }}
</button>
</form>
}
`,
styles: `
.single {
grid-template-columns: 1fr;
margin-bottom: var(--space-5);
}
`,
})
export default class ResetPassword {
private readonly api = inject(ApiService)
protected readonly token = takeTokenFromUrl()
protected readonly form = new FormGroup({
password: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(PASSWORD_MIN), Validators.maxLength(128)],
}),
confirm: new FormControl('', { nonNullable: true }),
})
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly done = signal(false)
protected readonly invalid = signal(false)
protected readonly error = signal<string | null>(null)
constructor() {
inject(Title).setTitle('Nuova password | Area riservata ITI TECHNOLOGY')
mustMatch(this.form.controls.password, this.form.controls.confirm)
}
protected async submit(): Promise<void> {
this.submitted.set(true)
this.error.set(null)
if (this.form.invalid || this.busy() || !this.token) return
this.busy.set(true)
try {
await firstValueFrom(
this.api.post('/auth/reset-password', { token: this.token, password: this.form.getRawValue().password }, { anonymous: true }),
)
this.done.set(true)
} catch (err) {
const error = toApiError(err)
if (error.code === 'INVALID_TOKEN' || (error.code === 'VALIDATION_ERROR' && error.details.some((d) => d.path === 'body.token'))) {
this.invalid.set(true)
} else {
this.error.set(authErrorMessage(error))
}
} finally {
this.busy.set(false)
}
}
}
@@ -0,0 +1,86 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { AuthStore } from '../../core/auth.store'
import { homeFor } from '../../core/roles'
import { authErrorMessage } from '../../shared/form-errors'
import { takeTokenFromUrl } from './auth-helpers'
type State = 'verifying' | 'verified' | 'invalid' | 'error' | 'missing'
/**
* Conferma dell'email dal link (`/verify-email?token=…`, 48 ore, monouso). Vale sia da anonimi sia con una
* sessione aperta: in quel caso l'utente viene riletto, così l'avviso "conferma la tua email" sparisce.
* La verifica collega anche gli ordini fatti come ospite e le richieste anonime con la stessa email (API).
*/
@Component({
selector: 'app-verify-email',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Conferma dell'email</h1>
@switch (state()) {
@case ('verifying') {
<p role="status">Verifica in corso…</p>
}
@case ('verified') {
<p class="alert alert--success" role="status">Email confermata, grazie!</p>
<p>Gli eventuali ordini fatti come ospite con questo indirizzo sono ora collegati al tuo account.</p>
<a [routerLink]="next()" class="btn btn--primary">{{ auth.isAuthenticated() ? 'Vai alla tua area' : 'Accedi' }}</a>
}
@case ('invalid') {
<p class="alert alert--error" role="alert">
Il link non è valido o è scaduto (vale 48 ore e si può usare una sola volta). Se hai già confermato l'email
puoi accedere normalmente.
</p>
<p>Per riceverne uno nuovo accedi: in cima all'area riservata trovi "Invia di nuovo il link".</p>
<a [routerLink]="next()" class="btn btn--primary">{{ auth.isAuthenticated() ? 'Vai alla tua area' : 'Accedi' }}</a>
}
@case ('missing') {
<p class="alert alert--error" role="alert">Il link non è completo: aprilo di nuovo dall'email.</p>
}
@default {
<p class="alert alert--error" role="alert">{{ error() }}</p>
<button type="button" class="btn btn--primary" (click)="verify()">Riprova</button>
}
}
`,
})
export default class VerifyEmail {
private readonly api = inject(ApiService)
protected readonly auth = inject(AuthStore)
private readonly token = takeTokenFromUrl()
protected readonly state = signal<State>(this.token ? 'verifying' : 'missing')
protected readonly error = signal('')
protected readonly next = computed(() => {
const role = this.auth.role()
return role ? homeFor(role) : '/login'
})
constructor() {
inject(Title).setTitle("Conferma dell'email | Area riservata ITI TECHNOLOGY")
void this.verify()
}
protected async verify(): Promise<void> {
if (!this.token) return
this.state.set('verifying')
try {
await firstValueFrom(this.api.post('/auth/verify-email', { token: this.token }, { anonymous: true }))
this.state.set('verified')
await this.auth.ready()
await this.auth.reloadUser().catch(() => undefined)
} catch (err) {
const error = toApiError(err)
if (error.code === 'INVALID_TOKEN' || error.code === 'VALIDATION_ERROR') {
this.state.set('invalid')
} else {
this.error.set(authErrorMessage(error))
this.state.set('error')
}
}
}
}
@@ -0,0 +1,172 @@
import type { DocumentDto, QuoteDto } from '@iti/shared'
import { provideHttpClient, withInterceptors } from '@angular/common/http'
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { provideRouter } from '@angular/router'
import { APP_CONFIG } from '../../core/app-config'
import { authInterceptor } from '../../core/auth.interceptor'
import { fileName } from '../../core/download.service'
import { formatDay, formatEuro } from '../../core/format'
import { orderStatus } from '../../core/labels'
import { pageFromQuery } from '../../shared/pager'
import { fileKind, groupByCategory } from './documents'
import { profilePatch } from './profile'
import QuoteDetail from './quote-detail'
const API = 'http://api.test'
const tick = () => new Promise((resolve) => setTimeout(resolve))
/** Lascia finire promise, richieste e change detection. */
const settle = async (fixture: { whenStable(): Promise<unknown> }) => {
await tick()
await tick()
await fixture.whenStable()
}
describe('area clienti: funzioni', () => {
it('stato dell’ordine in una sola etichetta', () => {
const o = (status: string, paymentStatus: string, fulfillmentStatus: string) => orderStatus({ status, paymentStatus, fulfillmentStatus })
expect(o('canceled', 'refunded', 'shipped').label).toBe('Annullato')
expect(o('pending', 'captured', 'delivered')).toEqual({ label: 'Consegnato', tone: 'success' })
expect(o('pending', 'captured', 'shipped').label).toBe('Spedito')
expect(o('pending', 'captured', 'fulfilled').label).toBe('In preparazione')
expect(o('pending', 'captured', 'not_fulfilled').label).toBe('Confermato')
expect(o('pending', 'not_paid', 'not_fulfilled')).toEqual({ label: 'In attesa di pagamento', tone: 'warning' })
expect(o('completed', 'captured', 'not_fulfilled').label).toBe('Completato')
})
it('documenti per categoria, "Altri documenti" in fondo', () => {
const doc = (id: string, category: string | null): DocumentDto => ({
id,
title: id,
category,
mimeType: 'application/pdf',
sizeBytes: 1,
visibility: 'customer',
productRef: null,
createdAt: '2026-01-01T00:00:00.000Z',
})
const groups = groupByCategory([doc('a', 'Manuali'), doc('b', null), doc('c', 'Certificazioni'), doc('d', 'Manuali'), doc('e', ' ')])
expect(groups.map((g) => [g.category, g.docs.map((d) => d.id)])).toEqual([
['Certificazioni', ['c']],
['Manuali', ['a', 'd']],
['Altri documenti', ['b', 'e']],
])
expect(fileKind('image/vnd.dwg')).toBe('DWG')
})
it('nome del file scaricato: caratteri non ammessi tolti, estensione dal MIME', () => {
expect(fileName('Scheda: pompa 8 kW / 2026', 'application/pdf')).toBe('Scheda pompa 8 kW 2026.pdf')
expect(fileName('manuale.pdf', 'application/pdf')).toBe('manuale.pdf')
expect(fileName(' ', 'image/vnd.dwg')).toBe('documento.dwg')
})
it('PATCH /me: campi aziendali solo per le aziende', () => {
const value = { firstName: ' Ada ', lastName: 'Rossi', companyName: ' Rossi Srl ', vatNumber: '123' }
expect(profilePatch('customer', value)).toEqual({ firstName: 'Ada', lastName: 'Rossi' })
expect(profilePatch('installer', value)).toEqual({ firstName: 'Ada', lastName: 'Rossi' })
expect(profilePatch('company', value)).toEqual({ firstName: 'Ada', lastName: 'Rossi', companyName: 'Rossi Srl', vatNumber: '123' })
})
it('pagina da ?pagina=, importi e date', () => {
expect([pageFromQuery(undefined), pageFromQuery('3'), pageFromQuery('0'), pageFromQuery('abc'), pageFromQuery('1.5')]).toEqual([1, 3, 1, 1, 1])
expect(formatEuro(549000)).toBe('5.490 €')
expect(formatDay('2026-10-31')).toBe('31 ottobre 2026')
})
})
describe('dettaglio preventivo', () => {
const quote: QuoteDto = {
id: '11111111-1111-4111-8111-111111111111',
quoteRequestId: null,
number: '2026-0007',
title: 'Impianto FV 6 kW',
status: 'sent',
amountCents: 990000,
hasPdf: true,
validUntil: '2026-12-31',
sentAt: '2026-10-01T10:00:00.000Z',
createdAt: '2026-09-30T10:00:00.000Z',
}
beforeAll(() => {
// jsdom non implementa (o implementa in parte) <dialog>
HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) {
this.setAttribute('open', '')
}
HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) {
this.removeAttribute('open')
}
})
async function render() {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
provideRouter([]),
{ provide: APP_CONFIG, useValue: { apiUrl: API, siteUrl: 'http://sito.test', medusaAdminUrl: 'http://medusa.test/app' } },
],
})
const http = TestBed.inject(HttpTestingController)
const fixture = TestBed.createComponent(QuoteDetail)
fixture.componentRef.setInput('id', quote.id)
fixture.detectChanges()
await tick()
http.expectOne(`${API}/me/quotes/${quote.id}`).flush(quote)
await fixture.whenStable()
return { fixture, http, el: fixture.nativeElement as HTMLElement }
}
const button = (el: HTMLElement, text: string) =>
[...el.querySelectorAll('button')].find((b) => b.textContent?.trim().startsWith(text)) as HTMLButtonElement
it('accetta: conferma, POST e stato aggiornato in pagina senza ricaricare', async () => {
const { fixture, http, el } = await render()
expect(el.textContent).toContain('Da valutare')
button(el, 'Accetta il preventivo').click()
await fixture.whenStable()
expect(el.querySelector('dialog')?.hasAttribute('open')).toBe(true)
button(el, 'Sì, accetto').click()
await tick()
const req = http.expectOne(`${API}/me/quotes/${quote.id}/accept`)
expect(req.request.method).toBe('POST')
req.flush({ ...quote, status: 'accepted' })
await settle(fixture)
expect(el.textContent).toContain('Accettato')
expect(el.textContent).toContain('Preventivo accettato')
expect(button(el, 'Accetta il preventivo')).toBeUndefined()
http.expectNone(`${API}/me/quotes/${quote.id}`) // nessuna rilettura
http.verify()
})
it('annulla: nessuna richiesta', async () => {
const { fixture, http, el } = await render()
button(el, 'Rifiuta').click()
await fixture.whenStable()
button(el, 'Annulla').click()
await fixture.whenStable()
http.verify()
expect(el.textContent).toContain('Da valutare')
})
it('409 (già scaduto): messaggio e preventivo riletto', async () => {
const { fixture, http, el } = await render()
button(el, 'Accetta il preventivo').click()
await fixture.whenStable()
button(el, 'Sì, accetto').click()
await tick()
http
.expectOne(`${API}/me/quotes/${quote.id}/accept`)
.flush({ error: { code: 'QUOTE_EXPIRED', message: 'Il preventivo è scaduto' } }, { status: 409, statusText: 'Conflict' })
// niente whenStable qui: la rilettura in corso è un'attività pendente e lo terrebbe in attesa
await tick()
await tick()
http.expectOne(`${API}/me/quotes/${quote.id}`).flush({ ...quote, status: 'expired' })
await settle(fixture)
expect(el.textContent).toContain('Il preventivo è scaduto')
expect(el.textContent).toContain('Scaduto')
http.verify()
})
})
@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { AuthStore } from '../../core/auth.store'
import { CustomerOverview } from './overview'
/** Panoramica dell'area clienti (privati e aziende). */
@Component({
selector: 'app-dashboard',
imports: [CustomerOverview],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Ciao {{ auth.user()?.firstName }}</h1>
<p class="lead">Qui trovi ordini, preventivi e richieste di assistenza.</p>
<app-customer-overview />
`,
})
export default class Dashboard {
protected readonly auth = inject(AuthStore)
constructor() {
inject(Title).setTitle('Panoramica | Area riservata ITI TECHNOLOGY')
}
}
@@ -0,0 +1,113 @@
import type { DocumentDto, Paginated } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { Title } from '@angular/platform-browser'
import { map } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { DownloadService, fileName } from '../../core/download.service'
import { formatBytes, formatDate } from '../../core/format'
import { Empty, Loading, LoadError } from '../../shared/state'
const OTHER = 'Altri documenti'
/** Etichetta del tipo di file dal MIME canonico salvato dall'API. */
export function fileKind(mimeType: string): string {
const kinds: Record<string, string> = {
'application/pdf': 'PDF',
'application/zip': 'ZIP',
'image/vnd.dwg': 'DWG',
'image/vnd.dxf': 'DXF',
'image/jpeg': 'JPG',
'image/png': 'PNG',
'image/webp': 'WebP',
'image/gif': 'GIF',
}
return kinds[mimeType] ?? 'File'
}
/** Documenti raggruppati per categoria (alfabetica, "Altri documenti" in fondo). */
export function groupByCategory(docs: DocumentDto[]): { category: string; docs: DocumentDto[] }[] {
const groups = new Map<string, DocumentDto[]>()
for (const doc of docs) {
const key = doc.category?.trim() || OTHER
groups.set(key, [...(groups.get(key) ?? []), doc])
}
return [...groups.entries()]
.sort(([a], [b]) => (a === OTHER ? 1 : b === OTHER ? -1 : a.localeCompare(b, 'it')))
.map(([category, list]) => ({ category, docs: list }))
}
/**
* Documenti scaricabili con il proprio ruolo (l'API filtra per visibilità: pubblici e clienti; per gli
* installatori approvati anche quelli tecnici). Il download passa dal token (DownloadService).
*/
@Component({
selector: 'app-documents',
imports: [Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Documenti</h1>
<p class="lead">Manuali, schede tecniche e altri documenti messi a disposizione per te.</p>
@if (res.isLoading()) {
<app-loading label="Caricamento dei documenti…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (groups().length) {
@if (downloadError(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
@for (g of groups(); track g.category) {
<section class="section">
<h2>{{ g.category }}</h2>
<ul class="item-list">
@for (d of g.docs; track d.id) {
<li class="item">
<span class="item__main">
<span class="item__title">{{ d.title }}</span>
<span class="meta">{{ kind(d.mimeType) }} · {{ bytes(d.sizeBytes) }} · {{ date(d.createdAt) }}</span>
</span>
<button type="button" class="btn btn--outline btn--small" [disabled]="downloading() === d.id" (click)="download(d)">
{{ downloading() === d.id ? 'Download…' : 'Scarica' }}<span class="visually-hidden"> {{ d.title }}</span>
</button>
</li>
}
</ul>
</section>
}
} @else {
<app-empty message="Al momento non ci sono documenti disponibili per il tuo account." />
}
`,
})
export default class Documents {
private readonly api = inject(ApiService)
private readonly downloads = inject(DownloadService)
protected readonly res = rxResource({
stream: () => this.api.get<Paginated<DocumentDto>>('/documents', { query: { limit: 100 } }).pipe(map((p) => p.items)),
})
protected readonly groups = computed(() => groupByCategory(this.res.value() ?? []))
protected readonly downloading = signal<string | null>(null)
protected readonly downloadError = signal<string | null>(null)
protected readonly kind = fileKind
protected readonly bytes = formatBytes
protected readonly date = formatDate
constructor() {
inject(Title).setTitle('Documenti | Area riservata ITI TECHNOLOGY')
}
protected async download(doc: DocumentDto): Promise<void> {
this.downloadError.set(null)
this.downloading.set(doc.id)
try {
await this.downloads.download(`/documents/${doc.id}/download`, fileName(doc.title, doc.mimeType))
} catch (err) {
this.downloadError.set(`Download di "${doc.title}" non riuscito: ${toApiError(err).message}`)
} finally {
this.downloading.set(null)
}
}
}
@@ -0,0 +1,203 @@
import type { MyOrderDetailDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { ApiError, ApiService } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { formatDate, formatEuro } from '../../core/format'
import { orderStatus } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { Empty, Loading, LoadError } from '../../shared/state'
/** Dettaglio ordine: stato, articoli, totali con IVA, spedizione e tracking. */
@Component({
selector: 'app-order-detail',
imports: [RouterLink, Badge, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/ordini" class="back-link">← I miei ordini</a>
@if (res.isLoading()) {
<app-loading label="Caricamento dell'ordine…" />
} @else if (notFound()) {
<h1>Ordine non trovato</h1>
<app-empty message="L'ordine non esiste o non è collegato al tuo account." />
} @else if (res.error()) {
<h1>Ordine n. {{ numero() }}</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as o) {
<div class="page-head">
<h1>Ordine n. {{ o.number }}</h1>
<app-badge [status]="status(o)" />
</div>
<p class="meta">Effettuato il {{ date(o.createdAt) }}</p>
<div class="layout">
<section class="panel section" aria-labelledby="od-items">
<h2 id="od-items">Articoli</h2>
<ul class="lines">
@for (i of o.items; track $index) {
<li>
@if (i.thumbnail) {
<img [src]="i.thumbnail" alt="" width="56" height="56" loading="lazy" />
}
<span class="line-main">
<span class="item__title">{{ i.title }}</span>
<span class="meta">
{{ i.quantity }} × {{ euro(i.unitPrice) }}
@if (i.sku) {
· cod. {{ i.sku }}
}
</span>
</span>
<span class="amount">{{ euro(i.total) }}</span>
</li>
}
</ul>
<dl class="totals">
<dt>Prodotti</dt>
<dd>{{ euro(o.totals.items) }}</dd>
@if (o.totals.discount > 0) {
<dt>Sconto</dt>
<dd>−{{ euro(o.totals.discount) }}</dd>
}
<dt>Spedizione</dt>
<dd>{{ o.totals.shipping > 0 ? euro(o.totals.shipping) : 'Gratuita' }}</dd>
<dt class="total">Totale</dt>
<dd class="total">{{ euro(o.totals.total) }}</dd>
<dt class="meta">di cui IVA</dt>
<dd class="meta">{{ euro(o.totals.tax) }}</dd>
</dl>
</section>
<section class="panel section" aria-labelledby="od-ship">
<h2 id="od-ship">Spedizione</h2>
@if (o.shippingAddress.length) {
<address>
@for (line of o.shippingAddress; track $index) {
{{ line }}<br />
}
</address>
}
@if (o.shippingMethods.length) {
<p><span class="meta">Metodo:</span> {{ o.shippingMethods.join(', ') }}</p>
}
@if (o.tracking.length) {
<h3>Tracciamento</h3>
<ul class="tracking">
@for (t of o.tracking; track t.number) {
<li>
@if (safeUrl(t.url); as url) {
<a [href]="url" target="_blank" rel="noopener noreferrer">{{ t.number }}</a>
} @else {
{{ t.number }}
}
</li>
}
</ul>
} @else if (o.status !== 'canceled') {
<p class="meta">Il codice di tracciamento comparirà qui quando l'ordine sarà spedito.</p>
}
</section>
</div>
<p>
Serve aiuto con questo ordine?
<a routerLink="/assistenza/nuova" [queryParams]="{ ordine: o.number }">Apri una richiesta di assistenza</a>
oppure <a [href]="contactUrl">contattaci</a>.
</p>
}
`,
styles: `
.layout {
display: grid;
gap: var(--space-5);
margin-top: var(--space-4);
@media (min-width: 1024px) {
grid-template-columns: 2fr 1fr;
align-items: start;
}
}
.lines {
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-line);
}
img {
flex: none;
width: 56px;
height: 56px;
object-fit: cover;
border-radius: var(--radius-sm);
}
}
.line-main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.totals {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-1) var(--space-4);
margin: 0;
dd {
margin: 0;
text-align: right;
}
.total {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
}
}
address {
font-style: normal;
margin-bottom: var(--space-3);
}
h3 {
font-size: var(--font-size-lg);
}
.tracking {
padding-left: var(--space-5);
}
`,
})
export default class OrderDetail {
private readonly api = inject(ApiService)
protected readonly contactUrl = `${inject(APP_CONFIG).siteUrl}/contatti`
readonly numero = input.required<string>()
protected readonly res = rxResource({
params: () => this.numero(),
stream: ({ params }) => this.api.get<MyOrderDetailDto>(`/me/orders/${encodeURIComponent(params)}`),
})
protected readonly notFound = computed(() => {
const err = this.res.error()
return err instanceof ApiError && (err.status === 404 || err.status === 400)
})
protected readonly status = orderStatus
protected readonly date = formatDate
protected readonly euro = formatEuro
/** Solo link http(s): l'URL di tracking arriva dal corriere tramite Medusa. */
protected safeUrl(url: string | null): string | null {
return url && /^https?:\/\//i.test(url) ? url : null
}
constructor() {
const title = inject(Title)
effect(() => title.setTitle(`Ordine n. ${this.numero()} | Area riservata ITI TECHNOLOGY`))
}
}
@@ -0,0 +1,82 @@
import type { MyOrderDto, Paginated } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { formatDate, formatEuro } from '../../core/format'
import { orderStatus } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
const PAGE_SIZE = 10
/** I miei ordini (da Medusa, compresi gli ordini fatti come ospite e collegati alla verifica dell'email). */
@Component({
selector: 'app-orders',
imports: [RouterLink, Badge, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page-head">
<h1>I miei ordini</h1>
<a class="btn btn--outline btn--small" [href]="shopUrl">Vai allo shop</a>
</div>
@if (res.isLoading()) {
<app-loading label="Caricamento degli ordini…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<ul class="item-list">
@for (o of data.items; track o.number) {
<li>
<a class="item" [routerLink]="['/ordini', o.number]">
<span class="item__main">
<span class="item__title">Ordine n. {{ o.number }}</span>
<span class="meta">{{ date(o.createdAt) }} · {{ count(o) }}</span>
</span>
<span class="item__side">
<span class="amount">{{ euro(o.totals.total) }}</span>
<app-badge [status]="status(o)" />
</span>
</a>
</li>
}
</ul>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty message="Non ci sono ordini collegati al tuo account.">
<p class="meta">Gli ordini fatti come ospite con la tua email compaiono qui dopo la conferma dell'indirizzo.</p>
</app-empty>
}
}
`,
})
export default class Orders {
private readonly api = inject(ApiService)
protected readonly shopUrl = `${inject(APP_CONFIG).siteUrl}/shop`
readonly pagina = input<string>()
protected readonly pageSize = PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly res = rxResource({
params: () => this.page(),
stream: ({ params }) =>
this.api.get<Paginated<MyOrderDto>>('/me/orders', { query: { limit: PAGE_SIZE, offset: (params - 1) * PAGE_SIZE } }),
})
protected readonly status = orderStatus
protected readonly date = formatDate
protected readonly euro = formatEuro
protected count(o: MyOrderDto): string {
const n = o.items.reduce((sum, i) => sum + i.quantity, 0)
return n === 1 ? '1 articolo' : `${n} articoli`
}
constructor() {
inject(Title).setTitle('I miei ordini | Area riservata ITI TECHNOLOGY')
}
}
@@ -0,0 +1,158 @@
import type { MyOrderDto, Paginated, QuoteDto, TicketDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { map } from 'rxjs'
import { ApiService } from '../../core/api.service'
import { formatDate, formatEuro } from '../../core/format'
import { isQuotePending, isTicketOpen, orderStatus, QUOTE_STATUS, TICKET_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { Empty, Loading, LoadError } from '../../shared/state'
/** Quanti elementi per riquadro. */
const LIMIT = 3
/**
* Riepilogo dell'area clienti: ultimi ordini, preventivi da valutare, ticket aperti. Ogni riquadro si carica
* e fallisce da solo (es. Medusa giù non blocca preventivi e ticket). Riusabile nella home installatori.
*/
@Component({
selector: 'app-customer-overview',
imports: [RouterLink, Badge, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="grid">
<section class="panel" aria-labelledby="ov-orders">
<div class="head">
<h2 id="ov-orders">Ultimi ordini</h2>
<a routerLink="/ordini">Tutti</a>
</div>
@if (orders.isLoading()) {
<app-loading />
} @else if (orders.error()) {
<app-load-error [error]="orders.error()" (retry)="orders.reload()" />
} @else if (orders.value()?.length) {
<ul class="item-list">
@for (o of orders.value(); track o.number) {
<li>
<a class="item" [routerLink]="['/ordini', o.number]">
<span class="item__main">
<span class="item__title">Ordine n. {{ o.number }}</span>
<span class="meta">{{ date(o.createdAt) }} · {{ euro(o.totals.total) }}</span>
</span>
<app-badge [status]="orderStatus(o)" />
</a>
</li>
}
</ul>
} @else {
<app-empty message="Non hai ancora fatto ordini." />
}
</section>
<section class="panel" aria-labelledby="ov-quotes">
<div class="head">
<h2 id="ov-quotes">Preventivi da valutare</h2>
<a routerLink="/preventivi">Tutti</a>
</div>
@if (quotes.isLoading()) {
<app-loading />
} @else if (quotes.error()) {
<app-load-error [error]="quotes.error()" (retry)="quotes.reload()" />
} @else if (pendingQuotes().length) {
<ul class="item-list">
@for (q of pendingQuotes(); track q.id) {
<li>
<a class="item" [routerLink]="['/preventivi', q.id]">
<span class="item__main">
<span class="item__title">{{ q.title }}</span>
<span class="meta">N. {{ q.number }} · {{ euro(q.amountCents) }}</span>
</span>
<app-badge [status]="quoteStatus[q.status]" />
</a>
</li>
}
</ul>
} @else {
<app-empty message="Nessun preventivo in attesa di risposta." />
}
</section>
<section class="panel" aria-labelledby="ov-tickets">
<div class="head">
<h2 id="ov-tickets">Richieste di assistenza aperte</h2>
<a routerLink="/assistenza">Tutte</a>
</div>
@if (tickets.isLoading()) {
<app-loading />
} @else if (tickets.error()) {
<app-load-error [error]="tickets.error()" (retry)="tickets.reload()" />
} @else if (openTickets().length) {
<ul class="item-list">
@for (t of openTickets(); track t.id) {
<li>
<a class="item" [routerLink]="['/assistenza', t.id]">
<span class="item__main">
<span class="item__title">{{ t.subject }}</span>
<span class="meta">Aggiornato il {{ date(t.updatedAt) }}</span>
</span>
<app-badge [status]="ticketStatus[t.status]" />
</a>
</li>
}
</ul>
} @else {
<app-empty message="Nessuna richiesta aperta.">
<a routerLink="/assistenza/nuova" class="btn btn--outline btn--small">Apri una richiesta</a>
</app-empty>
}
</section>
</div>
`,
styles: `
.grid {
display: grid;
gap: var(--space-5);
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
h2 {
margin: 0;
font-size: var(--font-size-xl);
}
a {
display: inline-flex;
align-items: center;
min-height: 44px;
}
}
`,
})
export class CustomerOverview {
private readonly api = inject(ApiService)
protected readonly orders = rxResource({
stream: () => this.api.get<Paginated<MyOrderDto>>('/me/orders', { query: { limit: LIMIT } }).pipe(map((p) => p.items)),
})
// L'API filtra per un solo stato: si leggono gli ultimi e si filtra qui (pochi elementi per cliente)
protected readonly quotes = rxResource({
stream: () => this.api.get<Paginated<QuoteDto>>('/me/quotes', { query: { limit: 50 } }).pipe(map((p) => p.items)),
})
protected readonly tickets = rxResource({
stream: () => this.api.get<Paginated<TicketDto>>('/me/tickets', { query: { limit: 50 } }).pipe(map((p) => p.items)),
})
protected readonly pendingQuotes = computed(() => (this.quotes.value() ?? []).filter((q) => isQuotePending(q.status)).slice(0, LIMIT))
protected readonly openTickets = computed(() => (this.tickets.value() ?? []).filter((t) => isTicketOpen(t.status)).slice(0, LIMIT))
protected readonly orderStatus = orderStatus
protected readonly quoteStatus = QUOTE_STATUS
protected readonly ticketStatus = TICKET_STATUS
protected readonly date = formatDate
protected readonly euro = formatEuro
}
@@ -0,0 +1,175 @@
<h1>Il mio account</h1>
<section class="panel section" aria-labelledby="pf-details">
<h2 id="pf-details">Dati personali</h2>
<p class="email">
<span class="meta">Email</span><br />
<strong>{{ auth.user()?.email }}</strong> · {{ roleLabel() }}<br />
<span class="meta">Per cambiare l'indirizzo email <a routerLink="/assistenza/nuova">scrivi all'assistenza</a>.</span>
</p>
@if (detailsState.message(); as m) {
<p class="alert" [class.alert--success]="m.ok" [class.alert--error]="!m.ok" role="status">{{ m.text }}</p>
}
<form [formGroup]="details" (ngSubmit)="saveDetails()" novalidate>
<div class="form-grid">
<app-form-field [control]="details.controls.firstName" label="Nome" autocomplete="given-name" [submitted]="detailsState.submitted()" />
<app-form-field [control]="details.controls.lastName" label="Cognome" autocomplete="family-name" [submitted]="detailsState.submitted()" />
@if (role() === 'company') {
<app-form-field
[control]="details.controls.companyName"
label="Ragione sociale"
autocomplete="organization"
[submitted]="detailsState.submitted()"
/>
<app-form-field
[control]="details.controls.vatNumber"
label="Partita IVA"
[submitted]="detailsState.submitted()"
[messages]="{ minlength: 'Partita IVA troppo corta' }"
/>
}
</div>
@if (role() === 'installer') {
<p class="meta">I dati aziendali (ragione sociale, P.IVA, sede, certificazioni) sono nel <a routerLink="/installatori/profilo">profilo aziendale</a>.</p>
}
<button type="submit" class="btn btn--primary" [disabled]="detailsState.busy()">
{{ detailsState.busy() ? 'Salvataggio…' : 'Salva i dati' }}
</button>
</form>
</section>
<section class="panel section" aria-labelledby="pf-password">
<h2 id="pf-password">Password</h2>
@if (passwordState.message(); as m) {
<p class="alert" [class.alert--success]="m.ok" [class.alert--error]="!m.ok" role="status">{{ m.text }}</p>
}
<form [formGroup]="password" (ngSubmit)="changePassword()" novalidate>
<div class="form-grid single">
<app-form-field
[control]="password.controls.currentPassword"
label="Password attuale"
type="password"
autocomplete="current-password"
[submitted]="passwordState.submitted()"
/>
<app-form-field
[control]="password.controls.newPassword"
label="Nuova password"
type="password"
autocomplete="new-password"
hint="Almeno 10 caratteri."
[submitted]="passwordState.submitted()"
/>
<app-form-field
[control]="password.controls.confirm"
label="Ripeti la nuova password"
type="password"
autocomplete="new-password"
[submitted]="passwordState.submitted()"
[messages]="{ mismatch: 'Le due password non coincidono' }"
/>
</div>
<button type="submit" class="btn btn--primary" [disabled]="passwordState.busy()">
{{ passwordState.busy() ? 'Salvataggio…' : 'Cambia password' }}
</button>
</form>
</section>
<section class="panel section" aria-labelledby="pf-consents">
<h2 id="pf-consents">Privacy e comunicazioni</h2>
@if (consents.isLoading() && !consents.hasValue()) {
<app-loading label="Caricamento dei consensi…" />
} @else if (consents.error()) {
<app-load-error [error]="consents.error()" (retry)="consents.reload()" />
} @else if (consents.value(); as c) {
@if (c.marketing) {
<p>Ricevi le nostre comunicazioni commerciali (novità e offerte via email).</p>
<button type="button" class="btn btn--outline" [disabled]="consentBusy()" (click)="setMarketing(false)">
{{ consentBusy() ? 'Attendi…' : 'Revoca il consenso marketing' }}
</button>
} @else {
<p>Non ricevi comunicazioni commerciali. Puoi iscriverti quando vuoi e revocare in qualsiasi momento.</p>
<button type="button" class="btn btn--outline" [disabled]="consentBusy()" (click)="setMarketing(true)">
{{ consentBusy() ? 'Attendi…' : 'Voglio ricevere novità e offerte' }}
</button>
}
@if (consentError(); as e) {
<p class="field-error" role="alert">{{ e }}</p>
}
<p class="meta">
Il trattamento dei dati per gestire account, ordini e richieste è descritto nell'<a [href]="siteUrl + '/privacy'" target="_blank" rel="noopener"
>informativa privacy</a
>.
</p>
@if (c.items.length) {
<details>
<summary>Registro dei consensi ({{ c.items.length }})</summary>
<div class="table-wrap">
<table class="consents">
<thead>
<tr>
<th scope="col">Tipo</th>
<th scope="col">Dato il</th>
<th scope="col">Dove</th>
<th scope="col">Stato</th>
</tr>
</thead>
<tbody>
@for (item of c.items; track item.id) {
<tr>
<td>{{ item.type === 'privacy' ? 'Privacy (v. ' + item.version + ')' : 'Marketing' }}</td>
<td>{{ dateTime(item.acceptedAt) }}</td>
<td>{{ sources[item.source ?? ''] ?? item.source ?? '—' }}</td>
<td>{{ item.revokedAt ? 'Revocato il ' + dateTime(item.revokedAt) : 'Attivo' }}</td>
</tr>
}
</tbody>
</table>
</div>
</details>
}
}
</section>
@if (role() !== 'admin') {
<section class="panel section danger-zone" aria-labelledby="pf-delete">
<h2 id="pf-delete">Cancellazione dell'account</h2>
<p>
L'account viene disattivato subito: non potrai più accedere e chiuderemo tutte le sessioni. I tuoi dati verranno poi
cancellati, salvo quelli che la legge ci obbliga a conservare (es. dati fiscali degli ordini).
</p>
@if (deletionState.message(); as m) {
<p class="alert alert--error" role="alert">{{ m.text }}</p>
}
<form [formGroup]="deletion" (ngSubmit)="askDeletion()" novalidate>
<div class="form-grid single">
<app-form-field
[control]="deletion.controls.password"
label="Password, per conferma"
type="password"
autocomplete="current-password"
[submitted]="deletionState.submitted()"
/>
<app-form-field
[control]="deletion.controls.reason"
label="Motivo"
[rows]="3"
[maxlength]="2000"
[required]="false"
[submitted]="deletionState.submitted()"
/>
</div>
<button type="submit" class="btn btn--danger">Disattiva e cancella l'account</button>
</form>
</section>
<app-confirm-dialog
#deleteDialog
title="Cancellare l'account?"
message="L'account verrà disattivato subito e non potrai più accedere. L'operazione non si può annullare dall'area riservata."
confirmLabel="Sì, cancella l'account"
[danger]="true"
[busy]="deletionState.busy()"
(confirmed)="confirmDeletion()"
/>
}
@@ -0,0 +1,268 @@
import type { ConsentDto, UserDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, viewChild } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { Router, RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { AuthStore, type SessionResponse } from '../../core/auth.store'
import { formatDateTime } from '../../core/format'
import { ROLE_LABELS } from '../../core/roles'
import { ConfirmDialog } from '../../shared/confirm-dialog'
import { applyServerErrors } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
import { Loading, LoadError } from '../../shared/state'
import { mustMatch, PASSWORD_MIN } from '../auth/auth-helpers'
interface ConsentsResponse {
marketing: boolean
items: ConsentDto[]
}
const CONSENT_SOURCES: Record<string, string> = {
register: 'registrazione',
contact: 'modulo contatti',
showroom: 'prenotazione showroom',
configurator: 'configuratore',
checkout: 'checkout',
account: 'area riservata',
}
/** Corpo di `PATCH /me`: campi aziendali solo per le aziende, stringhe vuote → null. */
export function profilePatch(role: UserDto['role'], value: { firstName: string; lastName: string; companyName: string; vatNumber: string }) {
return {
firstName: value.firstName.trim(),
lastName: value.lastName.trim(),
...(role === 'company' ? { companyName: value.companyName.trim(), vatNumber: value.vatNumber.trim() } : {}),
}
}
/**
* Il mio account: dati anagrafici (campi aziendali per le aziende), cambio password, consensi (marketing
* revocabile, registro), richiesta di cancellazione dell'account.
*/
@Component({
selector: 'app-profile',
imports: [ReactiveFormsModule, RouterLink, FormField, ConfirmDialog, Loading, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './profile.html',
styles: `
.single {
grid-template-columns: 1fr;
max-width: 32rem;
}
.form-grid {
margin-bottom: var(--space-4);
}
.email {
margin-bottom: var(--space-4);
}
.consents {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
th,
td {
padding: var(--space-2);
border-bottom: 1px solid var(--color-line);
text-align: left;
}
}
.table-wrap {
overflow-x: auto;
}
details summary {
min-height: 44px;
display: flex;
align-items: center;
cursor: pointer;
}
.danger-zone {
border-color: var(--color-danger);
}
`,
})
export default class Profile {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
protected readonly auth = inject(AuthStore)
protected readonly siteUrl = inject(APP_CONFIG).siteUrl
protected readonly role = computed(() => this.auth.role() ?? 'customer')
protected readonly roleLabel = computed(() => ROLE_LABELS[this.role()])
// --- Dati personali ---
protected readonly details = new FormGroup({
firstName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }),
lastName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }),
companyName: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200)] }),
vatNumber: new FormControl('', { nonNullable: true, validators: [Validators.minLength(5), Validators.maxLength(20)] }),
})
protected readonly detailsState = this.formState()
// --- Password ---
protected readonly password = new FormGroup({
currentPassword: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
newPassword: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(PASSWORD_MIN), Validators.maxLength(128)],
}),
confirm: new FormControl('', { nonNullable: true }),
})
protected readonly passwordState = this.formState()
// --- Consensi ---
protected readonly consents = rxResource({ stream: () => this.api.get<ConsentsResponse>('/me/consents') })
protected readonly consentBusy = signal(false)
protected readonly consentError = signal<string | null>(null)
// --- Cancellazione ---
protected readonly deletion = new FormGroup({
password: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
reason: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(2000)] }),
})
protected readonly deletionState = this.formState()
private readonly deleteDialog = viewChild<ConfirmDialog>('deleteDialog')
protected readonly dateTime = formatDateTime
protected readonly sources = CONSENT_SOURCES
constructor() {
inject(Title).setTitle('Il mio account | Area riservata ITI TECHNOLOGY')
mustMatch(this.password.controls.newPassword, this.password.controls.confirm)
// Form precompilato con l'utente corrente (anche dopo un salvataggio)
effect(() => {
const user = this.auth.user()
if (!user) return
this.details.reset({
firstName: user.firstName,
lastName: user.lastName,
companyName: user.companyName ?? '',
vatNumber: user.vatNumber ?? '',
})
const { companyName, vatNumber } = this.details.controls
if (user.role === 'company') {
companyName.setValidators([Validators.required, Validators.maxLength(200)])
vatNumber.setValidators([Validators.required, Validators.minLength(5), Validators.maxLength(20)])
} else {
companyName.clearValidators()
vatNumber.clearValidators()
}
companyName.updateValueAndValidity()
vatNumber.updateValueAndValidity()
})
}
protected async saveDetails(): Promise<void> {
const state = this.detailsState
state.start()
if (this.details.invalid) return state.stop()
try {
const user = await firstValueFrom(this.api.patch<UserDto>('/me', profilePatch(this.role(), this.details.getRawValue())))
this.auth.setUser(user)
state.done('Dati aggiornati.')
} catch (err) {
const error = toApiError(err)
state.fail(error.code === 'VALIDATION_ERROR' && applyServerErrors(this.details, error) ? 'Controlla i campi evidenziati.' : error.message)
}
}
protected async changePassword(): Promise<void> {
const state = this.passwordState
state.start()
if (this.password.invalid) return state.stop()
try {
const { currentPassword, newPassword } = this.password.getRawValue()
const session = await firstValueFrom(
this.api.post<SessionResponse>('/auth/change-password', { currentPassword, newPassword }, { withCredentials: true }),
)
this.auth.applySession(session)
this.password.reset()
state.done('Password aggiornata. Per sicurezza abbiamo chiuso le sessioni aperte sugli altri dispositivi.')
} catch (err) {
const error = toApiError(err)
if ((error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_CURRENT_PASSWORD') && applyServerErrors(this.password, error)) {
state.fail('Controlla i campi evidenziati.')
} else {
state.fail(error.code === 'RATE_LIMITED' ? 'Troppi tentativi: attendi un minuto e riprova.' : error.message)
}
}
}
protected async setMarketing(enabled: boolean): Promise<void> {
this.consentError.set(null)
this.consentBusy.set(true)
try {
const path = '/me/consents/marketing'
const res = await firstValueFrom(enabled ? this.api.post<ConsentsResponse>(path, null) : this.api.delete<ConsentsResponse>(path))
this.consents.set(res)
} catch (err) {
this.consentError.set(toApiError(err).message)
} finally {
this.consentBusy.set(false)
}
}
protected askDeletion(): void {
const state = this.deletionState
state.start()
if (this.deletion.invalid) return state.stop()
state.stop()
this.deleteDialog()?.open()
}
protected async confirmDeletion(): Promise<void> {
const state = this.deletionState
state.busy.set(true)
try {
const { password, reason } = this.deletion.getRawValue()
await firstValueFrom(this.api.post('/me/delete-account', { password, ...(reason.trim() ? { reason: reason.trim() } : {}) }))
this.deleteDialog()?.close()
// L'account è disattivato e le sessioni chiuse: si esce senza chiamare il logout
this.auth.forget()
await this.router.navigate(['/login'], { queryParams: { account: 'disattivato' }, replaceUrl: true })
} catch (err) {
this.deleteDialog()?.close()
const error = toApiError(err)
if (error.code === 'INVALID_CREDENTIALS') {
this.deletion.controls.password.setErrors({ server: 'Password non corretta' })
state.fail('Password non corretta: l’account non è stato disattivato.')
} else {
state.fail(error.message)
}
} finally {
state.busy.set(false)
}
}
/** Stato di invio di un form: tentativo fatto, in corso, messaggio di esito. */
private formState() {
const submitted = signal(false)
const busy = signal(false)
const message = signal<{ ok: boolean; text: string } | null>(null)
return {
submitted,
busy,
message,
start: () => {
submitted.set(true)
message.set(null)
busy.set(true)
},
stop: () => busy.set(false),
done: (text: string) => {
busy.set(false)
submitted.set(false)
message.set({ ok: true, text })
},
fail: (text: string) => {
busy.set(false)
message.set({ ok: false, text })
},
}
}
}
@@ -0,0 +1,190 @@
import type { QuoteDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, viewChild } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiError, ApiService, toApiError } from '../../core/api.service'
import { DownloadService } from '../../core/download.service'
import { formatDate, formatDay, formatEuro } from '../../core/format'
import { isQuotePending, QUOTE_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { ConfirmDialog } from '../../shared/confirm-dialog'
import { Empty, Loading, LoadError } from '../../shared/state'
type Decision = 'accept' | 'reject'
/**
* Dettaglio preventivo: importo, validità, PDF e risposta. Accetta/rifiuta chiedono conferma e aggiornano
* lo stato in pagina con la risposta dell'API (nessun ricaricamento).
*/
@Component({
selector: 'app-quote-detail',
imports: [RouterLink, Badge, ConfirmDialog, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/preventivi" class="back-link">← Preventivi</a>
@if (res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento del preventivo…" />
} @else if (notFound()) {
<h1>Preventivo non trovato</h1>
<app-empty message="Il preventivo non esiste o non è collegato al tuo account." />
} @else if (res.error()) {
<h1>Preventivo</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as q) {
<div class="page-head">
<h1>{{ q.title }}</h1>
<app-badge [status]="labels[q.status]" />
</div>
@if (notice(); as n) {
<p class="alert" [class.alert--success]="n.ok" [class.alert--error]="!n.ok" role="status">{{ n.text }}</p>
}
<section class="panel section">
<dl class="facts">
<dt>Numero</dt>
<dd>{{ q.number }}</dd>
<dt>Importo</dt>
<dd class="amount">{{ euro(q.amountCents) }} <span class="meta">IVA inclusa</span></dd>
@if (q.sentAt) {
<dt>Ricevuto il</dt>
<dd>{{ date(q.sentAt) }}</dd>
}
<dt>Validità</dt>
<dd>{{ q.validUntil ? 'fino al ' + day(q.validUntil) : 'senza scadenza' }}</dd>
</dl>
<div class="actions">
@if (q.hasPdf) {
<button type="button" class="btn btn--outline" (click)="downloadPdf(q)" [disabled]="downloading()">
{{ downloading() ? 'Download in corso…' : 'Scarica il PDF' }}
</button>
}
@if (pending()) {
<button type="button" class="btn btn--primary" (click)="ask('accept')">Accetta il preventivo</button>
<button type="button" class="btn btn--outline" (click)="ask('reject')">Rifiuta</button>
}
</div>
@if (downloadError(); as message) {
<p class="field-error" role="alert">{{ message }}</p>
}
</section>
@switch (q.status) {
@case ('accepted') {
<p>Hai accettato questo preventivo: ti contatteremo per organizzare i prossimi passi.</p>
}
@case ('rejected') {
<p>Hai rifiutato questo preventivo. Se hai cambiato idea o vuoi una proposta diversa, <a routerLink="/assistenza/nuova">scrivici</a>.</p>
}
@case ('expired') {
<p>Il preventivo è scaduto. Per una nuova proposta aggiornata <a routerLink="/assistenza/nuova">scrivici</a>.</p>
}
}
<app-confirm-dialog
[title]="decision() === 'accept' ? 'Accettare il preventivo?' : 'Rifiutare il preventivo?'"
[message]="
decision() === 'accept'
? 'Confermi di accettare il preventivo ' + q.number + ' di ' + euro(q.amountCents) + '? Ti contatteremo per i prossimi passi.'
: 'Confermi di rifiutare il preventivo ' + q.number + '? Non potrai più accettarlo.'
"
[confirmLabel]="decision() === 'accept' ? 'Sì, accetto' : 'Sì, rifiuto'"
[danger]="decision() === 'reject'"
[busy]="sending()"
(confirmed)="send(q)"
(cancelled)="decision.set(null)"
/>
}
`,
styles: `
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: var(--space-5);
}
`,
})
export default class QuoteDetail {
private readonly api = inject(ApiService)
private readonly downloads = inject(DownloadService)
readonly id = input.required<string>()
protected readonly res = rxResource({
params: () => this.id(),
stream: ({ params }) => this.api.get<QuoteDto>(`/me/quotes/${encodeURIComponent(params)}`),
})
protected readonly notFound = computed(() => {
const err = this.res.error()
return err instanceof ApiError && (err.status === 404 || err.status === 400)
})
protected readonly pending = computed(() => {
const q = this.res.value()
return q ? isQuotePending(q.status) : false
})
protected readonly decision = signal<Decision | null>(null)
protected readonly sending = signal(false)
protected readonly notice = signal<{ ok: boolean; text: string } | null>(null)
protected readonly downloading = signal(false)
protected readonly downloadError = signal<string | null>(null)
private readonly dialog = viewChild(ConfirmDialog)
protected readonly labels = QUOTE_STATUS
protected readonly date = formatDate
protected readonly day = formatDay
protected readonly euro = formatEuro
constructor() {
const title = inject(Title)
effect(() => {
const q = this.res.value()
title.setTitle(`${q ? `Preventivo ${q.number}` : 'Preventivo'} | Area riservata ITI TECHNOLOGY`)
})
}
protected ask(decision: Decision): void {
this.notice.set(null)
this.decision.set(decision)
this.dialog()?.open()
}
protected async send(quote: QuoteDto): Promise<void> {
const decision = this.decision()
if (!decision || this.sending()) return
this.sending.set(true)
try {
const updated = await firstValueFrom(this.api.post<QuoteDto>(`/me/quotes/${quote.id}/${decision}`, null))
this.res.set(updated)
this.notice.set({
ok: true,
text: decision === 'accept' ? 'Preventivo accettato. Grazie!' : 'Preventivo rifiutato.',
})
} catch (err) {
const error = toApiError(err)
this.notice.set({ ok: false, text: error.message })
// Stato cambiato altrove (scaduto, già risposto): si rilegge il preventivo
if (error.status === 409) this.res.reload()
} finally {
this.sending.set(false)
this.decision.set(null)
this.dialog()?.close()
}
}
protected async downloadPdf(quote: QuoteDto): Promise<void> {
this.downloadError.set(null)
this.downloading.set(true)
try {
await this.downloads.download(`/me/quotes/${quote.id}/pdf`, `Preventivo ${quote.number}.pdf`)
} catch (err) {
this.downloadError.set(toApiError(err).message)
} finally {
this.downloading.set(false)
}
}
}
@@ -0,0 +1,87 @@
import type { Paginated, QuoteDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { formatDate, formatDay, formatEuro } from '../../core/format'
import { QUOTE_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
const PAGE_SIZE = 10
/** Preventivi ricevuti (le bozze dell'ufficio non sono visibili). */
@Component({
selector: 'app-quotes',
imports: [RouterLink, Badge, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page-head">
<h1>Preventivi</h1>
<a class="btn btn--outline btn--small" [href]="configuratorUrl">Richiedi un preventivo</a>
</div>
@if (res.isLoading()) {
<app-loading label="Caricamento dei preventivi…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<ul class="item-list">
@for (q of data.items; track q.id) {
<li>
<a class="item" [routerLink]="['/preventivi', q.id]">
<span class="item__main">
<span class="item__title">{{ q.title }}</span>
<span class="meta">
N. {{ q.number }}
@if (q.sentAt) {
· ricevuto il {{ date(q.sentAt) }}
}
@if (q.status === 'sent' && q.validUntil) {
· valido fino al {{ day(q.validUntil) }}
}
</span>
</span>
<span class="item__side">
<span class="amount">{{ euro(q.amountCents) }}</span>
<app-badge [status]="labels[q.status]" />
</span>
</a>
</li>
}
</ul>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty message="Non hai ancora ricevuto preventivi.">
<p class="meta">Puoi chiederne uno con il configuratore o dalla pagina contatti del sito.</p>
</app-empty>
}
}
`,
})
export default class Quotes {
private readonly api = inject(ApiService)
protected readonly configuratorUrl = `${inject(APP_CONFIG).siteUrl}/configuratore`
readonly pagina = input<string>()
protected readonly pageSize = PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly res = rxResource({
params: () => this.page(),
stream: ({ params }) =>
this.api.get<Paginated<QuoteDto>>('/me/quotes', { query: { limit: PAGE_SIZE, offset: (params - 1) * PAGE_SIZE } }),
})
protected readonly labels = QUOTE_STATUS
protected readonly date = formatDate
protected readonly day = formatDay
protected readonly euro = formatEuro
constructor() {
inject(Title).setTitle('Preventivi | Area riservata ITI TECHNOLOGY')
}
}
@@ -0,0 +1,162 @@
import type { TicketDetailDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { Router, RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiError, ApiService, toApiError } from '../../core/api.service'
import { formatDateTime } from '../../core/format'
import { TICKET_PRIORITY, TICKET_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { FormField } from '../../shared/form-field'
import { Empty, Loading, LoadError } from '../../shared/state'
/**
* Conversazione di un ticket. La risposta del cliente rimette il ticket "aperto" (anche se era chiuso) e
* aggiorna la pagina con il dettaglio restituito dall'API.
*/
@Component({
selector: 'app-ticket-detail',
imports: [ReactiveFormsModule, RouterLink, Badge, FormField, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/assistenza" class="back-link">← Assistenza</a>
@if (res.isLoading() && !res.hasValue()) {
<app-loading label="Caricamento della richiesta…" />
} @else if (notFound()) {
<h1>Richiesta non trovata</h1>
<app-empty message="La richiesta non esiste o non è collegata al tuo account." />
} @else if (res.error()) {
<h1>Richiesta di assistenza</h1>
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as t) {
<div class="page-head">
<h1>{{ t.subject }}</h1>
<app-badge [status]="labels[t.status]" />
</div>
<p class="meta">Aperta il {{ dateTime(t.createdAt) }} · priorità {{ priority[t.priority].toLowerCase() }}</p>
@if (created) {
<p class="alert alert--success" role="status">Richiesta inviata: ti avviseremo via email quando risponderemo.</p>
}
<ol class="thread" aria-label="Messaggi">
@for (m of t.messages; track m.id) {
<li [class.staff]="m.fromStaff">
<p class="who">
<strong>{{ m.fromStaff ? 'Assistenza ITI TECHNOLOGY' : 'Tu' }}</strong>
<span class="meta">{{ dateTime(m.createdAt) }}</span>
</p>
<p class="body">{{ m.body }}</p>
</li>
}
</ol>
<form class="panel" (submit)="$event.preventDefault(); reply(t)" novalidate>
<app-form-field
[control]="message"
[label]="t.status === 'closed' ? 'Scrivi per riaprire la richiesta' : 'La tua risposta'"
[rows]="5"
[maxlength]="5000"
[submitted]="submitted()"
/>
@if (error(); as e) {
<p class="field-error" role="alert">{{ e }}</p>
}
<button type="submit" class="btn btn--primary" [disabled]="busy()">{{ busy() ? 'Invio…' : 'Invia' }}</button>
</form>
}
`,
styles: `
.thread {
display: grid;
gap: var(--space-3);
margin: var(--space-5) 0;
padding: 0;
list-style: none;
li {
max-width: 48rem;
padding: var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface);
}
li.staff {
border-color: var(--color-primary);
background: var(--color-primary-soft);
}
}
.who {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.body {
margin: 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
app-form-field {
margin-bottom: var(--space-4);
}
`,
})
export default class TicketDetail {
private readonly api = inject(ApiService)
readonly id = input.required<string>()
/** Arrivati dalla creazione del ticket (stato della navigazione). */
protected readonly created = Boolean((inject(Router).currentNavigation()?.extras.state as { created?: boolean } | undefined)?.created)
protected readonly res = rxResource({
params: () => this.id(),
stream: ({ params }) => this.api.get<TicketDetailDto>(`/me/tickets/${encodeURIComponent(params)}`),
})
protected readonly notFound = computed(() => {
const err = this.res.error()
return err instanceof ApiError && (err.status === 404 || err.status === 400)
})
protected readonly message = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(5000)] })
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly error = signal<string | null>(null)
protected readonly labels = TICKET_STATUS
protected readonly priority = TICKET_PRIORITY
protected readonly dateTime = formatDateTime
constructor() {
const title = inject(Title)
effect(() => {
const t = this.res.value()
title.setTitle(`${t ? t.subject : 'Richiesta di assistenza'} | Area riservata ITI TECHNOLOGY`)
})
}
protected async reply(ticket: TicketDetailDto): Promise<void> {
this.submitted.set(true)
this.error.set(null)
if (this.message.invalid || this.message.value.trim() === '' || this.busy()) {
this.message.setErrors({ required: true })
return
}
this.busy.set(true)
try {
const updated = await firstValueFrom(
this.api.post<TicketDetailDto>(`/me/tickets/${ticket.id}/messages`, { body: this.message.value.trim() }),
)
this.res.set(updated)
this.message.reset()
this.submitted.set(false)
} catch (err) {
const error = toApiError(err)
this.error.set(error.code === 'RATE_LIMITED' ? 'Troppi messaggi in poco tempo: riprova più tardi.' : error.message)
} finally {
this.busy.set(false)
}
}
}
@@ -0,0 +1,97 @@
import type { TicketDetailDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, effect, inject, input, signal } from '@angular/core'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { Router, RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { applyServerErrors } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
/** Nuova richiesta di assistenza. `?ordine=12` precompila l'oggetto (link dal dettaglio ordine). */
@Component({
selector: 'app-ticket-new',
imports: [ReactiveFormsModule, RouterLink, FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a routerLink="/assistenza" class="back-link">← Assistenza</a>
<h1>Nuova richiesta di assistenza</h1>
<p class="lead">Descrivi il problema o la domanda: ti risponderemo qui e ti avviseremo via email.</p>
@if (error(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
<form class="panel" [formGroup]="form" (ngSubmit)="submit()" novalidate>
<div class="form-grid single">
<app-form-field [control]="form.controls.subject" label="Oggetto" [maxlength]="200" [submitted]="submitted()"
[messages]="{ minlength: 'Almeno 3 caratteri' }" />
<div class="form-field">
<label for="ticket-priority">Priorità</label>
<select id="ticket-priority" [formControl]="form.controls.priority">
<option value="low">Bassa: una domanda, nessuna urgenza</option>
<option value="normal">Normale</option>
<option value="high">Alta: impianto fermo o guasto</option>
</select>
</div>
<app-form-field
[control]="form.controls.body"
label="Messaggio"
[rows]="8"
[maxlength]="5000"
hint="Indica se puoi modello, matricola o numero d'ordine."
[submitted]="submitted()"
/>
</div>
<button type="submit" class="btn btn--primary" [disabled]="busy()">{{ busy() ? 'Invio in corso…' : 'Invia la richiesta' }}</button>
</form>
`,
styles: `
.single {
grid-template-columns: 1fr;
margin-bottom: var(--space-5);
}
`,
})
export default class TicketNew {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
readonly ordine = input<string>()
protected readonly form = new FormGroup({
subject: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(3), Validators.maxLength(200)] }),
priority: new FormControl<'low' | 'normal' | 'high'>('normal', { nonNullable: true }),
body: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(5000)] }),
})
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly error = signal<string | null>(null)
constructor() {
inject(Title).setTitle('Nuova richiesta | Area riservata ITI TECHNOLOGY')
effect(() => {
const order = Number(this.ordine())
if (Number.isInteger(order) && order > 0 && this.form.controls.subject.value === '') {
this.form.controls.subject.setValue(`Ordine n. ${order}`)
}
})
}
protected async submit(): Promise<void> {
this.submitted.set(true)
this.error.set(null)
if (this.form.invalid || this.busy()) return
this.busy.set(true)
try {
const { subject, priority, body } = this.form.getRawValue()
const created = await firstValueFrom(this.api.post<TicketDetailDto>('/me/tickets', { subject: subject.trim(), priority, body: body.trim() }))
await this.router.navigate(['/assistenza', created.id], { replaceUrl: true, state: { created: true } })
} catch (err) {
const error = toApiError(err)
if (error.code === 'VALIDATION_ERROR' && applyServerErrors(this.form, error)) this.error.set('Controlla i campi evidenziati.')
else if (error.code === 'RATE_LIMITED') this.error.set('Hai aperto molte richieste nell’ultima ora: riprova più tardi.')
else this.error.set(error.message)
} finally {
this.busy.set(false)
}
}
}
@@ -0,0 +1,125 @@
import type { Paginated, TicketDto, TicketStatus } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { formatDateTime } from '../../core/format'
import { TICKET_PRIORITY, TICKET_STATUS } from '../../core/labels'
import { Badge } from '../../shared/badge'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
const PAGE_SIZE = 10
const FILTERS: { value: TicketStatus | null; label: string }[] = [
{ value: null, label: 'Tutte' },
{ value: 'waiting_customer', label: 'In attesa di tua risposta' },
{ value: 'open', label: 'Aperte' },
{ value: 'in_progress', label: 'In lavorazione' },
{ value: 'closed', label: 'Chiuse' },
]
/** Richieste di assistenza (ticket), dalla più recente; filtro per stato con `?stato=`. */
@Component({
selector: 'app-tickets',
imports: [RouterLink, Badge, Pager, Loading, Empty, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page-head">
<h1>Assistenza</h1>
<a routerLink="/assistenza/nuova" class="btn btn--primary btn--small">Nuova richiesta</a>
</div>
<nav class="filters" aria-label="Filtra per stato">
@for (f of filters; track f.label) {
<a
[routerLink]="[]"
[queryParams]="{ stato: f.value, pagina: null }"
[attr.aria-current]="status() === f.value ? 'page' : null"
>{{ f.label }}</a
>
}
</nav>
@if (res.isLoading()) {
<app-loading label="Caricamento delle richieste…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<ul class="item-list">
@for (t of data.items; track t.id) {
<li>
<a class="item" [routerLink]="['/assistenza', t.id]">
<span class="item__main">
<span class="item__title">{{ t.subject }}</span>
<span class="meta">Aggiornata il {{ dateTime(t.updatedAt) }} · priorità {{ priority[t.priority].toLowerCase() }}</span>
</span>
<app-badge [status]="labels[t.status]" />
</a>
</li>
}
</ul>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty [message]="status() ? 'Nessuna richiesta con questo stato.' : 'Non hai ancora aperto richieste di assistenza.'">
@if (!status()) {
<a routerLink="/assistenza/nuova" class="btn btn--outline btn--small">Apri una richiesta</a>
}
</app-empty>
}
}
`,
styles: `
.filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-4);
a {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-ink);
text-decoration: none;
&[aria-current='page'] {
border-color: var(--color-primary);
background: var(--color-primary-soft);
color: var(--color-primary-strong);
font-weight: var(--font-weight-semibold);
}
}
}
`,
})
export default class Tickets {
private readonly api = inject(ApiService)
readonly pagina = input<string>()
readonly stato = input<string>()
protected readonly filters = FILTERS
protected readonly pageSize = PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly status = computed(() => FILTERS.find((f) => f.value === this.stato())?.value ?? null)
protected readonly res = rxResource({
params: () => ({ page: this.page(), status: this.status() }),
stream: ({ params }) =>
this.api.get<Paginated<TicketDto>>('/me/tickets', {
query: { limit: PAGE_SIZE, offset: (params.page - 1) * PAGE_SIZE, status: params.status },
}),
})
protected readonly labels = TICKET_STATUS
protected readonly priority = TICKET_PRIORITY
protected readonly dateTime = formatDateTime
constructor() {
inject(Title).setTitle('Assistenza | Area riservata ITI TECHNOLOGY')
}
}
@@ -0,0 +1,59 @@
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { InstallerProfileStore } from '../../core/installer-profile.store'
import { Loading, LoadError } from '../../shared/state'
/**
* Avviso al posto dei contenuti riservati agli installatori approvati (listino, documentazione tecnica): stato
* della richiesta e invito a completare il profilo. Le pagine lo usano così, e caricano i dati solo da approvati
* (nessun `<ng-content>`: il contenuto proiettato verrebbe istanziato comunque):
* `@if (store.approved()) { … } @else { <app-approval-gate what="…" /> }`
*/
@Component({
selector: 'app-approval-gate',
imports: [RouterLink, Loading, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@switch (store.state()) {
@case (null) {
@if (store.resource.error()) {
<app-load-error [error]="store.resource.error()" (retry)="store.resource.reload()" />
} @else {
<app-loading />
}
}
@case ('rejected') {
<div class="panel notice" role="status">
<p><strong>{{ what() }} non è disponibile.</strong> La tua richiesta come installatore non è stata approvata.</p>
<p>Per chiarimenti <a routerLink="/assistenza/nuova">scrivi all'assistenza</a>.</p>
</div>
}
@default {
<div class="panel notice" role="status">
<p>
<strong>{{ what() }} sarà disponibile dopo l'approvazione</strong> del tuo profilo installatore: la stiamo
valutando.
</p>
@if (store.missing().length) {
<p>Completa il profilo per velocizzare la verifica (mancano: {{ store.missing().join(', ') }}).</p>
}
<a routerLink="/installatori/profilo" class="btn btn--primary btn--small">{{ cta() }}</a>
</div>
}
}
`,
styles: `
.notice {
border-left: 4px solid var(--color-accent);
p:last-of-type {
margin-bottom: var(--space-4);
}
}
`,
})
export class ApprovalGate {
protected readonly store = inject(InstallerProfileStore)
/** Nome della sezione ("Il listino riservato", "La documentazione tecnica"). */
readonly what = input.required<string>()
protected readonly cta = computed(() => (this.store.state() === 'missing' ? 'Compila il profilo' : 'Completa il profilo'))
}
@@ -0,0 +1,179 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { AuthStore } from '../../core/auth.store'
import { formatDate } from '../../core/format'
import { InstallerProfileStore } from '../../core/installer-profile.store'
import { Loading, LoadError } from '../../shared/state'
import { CustomerOverview } from '../customer/overview'
/**
* Home dell'installatore.
* - In attesa (o profilo da compilare): pagina di attesa con i passaggi della richiesta e i dati mancanti.
* - Rifiutato: esito e contatto con l'assistenza.
* - Approvato: accesso rapido a listino, documentazione e profilo, più il riepilogo dell'area clienti
* (ordini, preventivi, assistenza restano disponibili anche all'installatore).
*/
@Component({
selector: 'app-installer-home',
imports: [RouterLink, CustomerOverview, Loading, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Ciao {{ auth.user()?.firstName }}</h1>
@switch (store.state()) {
@case (null) {
@if (store.resource.error()) {
<app-load-error [error]="store.resource.error()" (retry)="store.resource.reload()" />
} @else {
<app-loading label="Caricamento del profilo…" />
}
}
@case ('approved') {
<p class="lead">{{ activeSince() }}</p>
<ul class="shortcuts">
<li>
<a routerLink="/installatori/listino" class="panel shortcut">
<strong>Listino riservato</strong>
<span class="meta">I tuoi prezzi a confronto con il listino pubblico.</span>
</a>
</li>
<li>
<a routerLink="/installatori/documenti" class="panel shortcut">
<strong>Documentazione tecnica</strong>
<span class="meta">Manuali, file CAD, certificazioni e listini.</span>
</a>
</li>
<li>
<a routerLink="/installatori/profilo" class="panel shortcut">
<strong>Profilo aziendale</strong>
<span class="meta">Dati, certificazioni, sede e zona di intervento.</span>
</a>
</li>
</ul>
<app-customer-overview />
}
@case ('rejected') {
<div class="panel" role="status">
<h2>Richiesta non approvata</h2>
<p>
Non abbiamo potuto approvare il tuo profilo installatore: listino riservato e documentazione tecnica non sono
disponibili. Puoi comunque acquistare dallo shop e usare ordini, preventivi e assistenza.
</p>
<a routerLink="/assistenza/nuova" class="btn btn--primary btn--small">Scrivi all'assistenza</a>
</div>
}
@default {
<div class="panel waiting" role="status">
<h2>La tua richiesta è in valutazione</h2>
<p>Verifichiamo i dati di ogni installatore prima di dare accesso al listino riservato e alla documentazione tecnica.</p>
<ol class="steps">
<li class="done"><span class="mark" aria-hidden="true">✓</span> Registrazione come installatore</li>
<li [class.done]="complete()">
<span class="mark" aria-hidden="true">{{ complete() ? '✓' : '2' }}</span>
<span>
Profilo aziendale completo
@if (!complete()) {
<span class="meta">— mancano: {{ store.missing().join(', ') }}</span>
}
</span>
</li>
<li class="current"><span class="mark" aria-hidden="true">3</span> Verifica da parte del nostro staff (riceverai un'email)</li>
<li><span class="mark" aria-hidden="true">4</span> Accesso a listino riservato e documentazione tecnica</li>
</ol>
<a routerLink="/installatori/profilo" class="btn btn--primary">
{{ complete() ? 'Rivedi il profilo' : 'Completa il profilo' }}
</a>
</div>
<p class="meta after">Nel frattempo puoi acquistare dallo shop ai prezzi di listino e usare ordini, preventivi e assistenza.</p>
}
}
`,
styles: `
.shortcuts {
display: grid;
gap: var(--space-4);
margin: 0 0 var(--space-6);
padding: 0;
list-style: none;
@media (min-width: 768px) {
grid-template-columns: repeat(3, 1fr);
}
}
.shortcut {
display: flex;
flex-direction: column;
gap: var(--space-1);
height: 100%;
color: var(--color-ink);
text-decoration: none;
strong {
color: var(--color-primary);
}
&:hover {
border-color: var(--color-primary);
}
}
.waiting {
border-left: 4px solid var(--color-accent);
}
.steps {
display: grid;
gap: var(--space-3);
margin: var(--space-4) 0 var(--space-5);
padding: 0;
list-style: none;
li {
display: flex;
align-items: flex-start;
gap: var(--space-3);
color: var(--color-ink-muted);
}
li.done,
li.current {
color: var(--color-ink);
}
li.current {
font-weight: var(--font-weight-semibold);
}
}
.mark {
flex: none;
display: inline-grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: var(--color-surface-alt);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
}
.done .mark {
background: var(--color-secondary-soft);
color: var(--color-success);
}
.current .mark {
background: var(--color-accent-soft);
color: var(--color-warning);
}
.after {
margin-top: var(--space-4);
}
`,
})
export default class InstallerHome {
protected readonly auth = inject(AuthStore)
protected readonly store = inject(InstallerProfileStore)
protected readonly complete = computed(() => this.store.missing().length === 0)
protected readonly activeSince = computed(() => {
const at = this.store.profile()?.approvedAt
return at ? `Il tuo profilo installatore è attivo dal ${formatDate(at)}.` : 'Il tuo profilo installatore è attivo.'
})
constructor() {
inject(Title).setTitle('Area installatore | Area riservata ITI TECHNOLOGY')
}
}
@@ -0,0 +1,132 @@
<h1>Profilo aziendale</h1>
@if (store.state() === null) {
@if (store.resource.error()) {
<app-load-error [error]="store.resource.error()" (retry)="store.resource.reload()" />
} @else {
<app-loading label="Caricamento del profilo…" />
}
} @else {
<p class="status" [attr.data-state]="store.state()" role="status">
{{ statusText() }}
@if (store.profile()?.approvedAt; as at) {
<span class="meta">(dal {{ date(at) }})</span>
}
</p>
@if (message(); as m) {
<p class="alert" [class.alert--success]="m.ok" [class.alert--error]="!m.ok" role="status">{{ m.text }}</p>
}
<form [formGroup]="form" (ngSubmit)="save()" novalidate>
<section class="panel section" aria-labelledby="ip-company">
<h2 id="ip-company">Dati aziendali</h2>
<div class="form-grid">
<app-form-field
class="span-2"
[control]="form.controls.businessName"
label="Ragione sociale"
autocomplete="organization"
[submitted]="submitted()"
[messages]="{ minlength: 'Almeno 2 caratteri' }"
/>
<app-form-field [control]="form.controls.vatNumber" label="Partita IVA" [required]="false" [submitted]="submitted()" />
<app-form-field
[control]="form.controls.phone"
label="Telefono"
type="tel"
autocomplete="tel"
inputmode="tel"
[required]="false"
[submitted]="submitted()"
/>
<app-form-field
class="span-2"
[control]="form.controls.address"
label="Indirizzo della sede"
[rows]="2"
[maxlength]="300"
[required]="false"
hint="Via, CAP, città e provincia."
[submitted]="submitted()"
/>
</div>
</section>
<section class="panel section" aria-labelledby="ip-area">
<h2 id="ip-area">Zona di intervento</h2>
<p class="meta">
Usiamo la posizione della sede e il raggio di servizio per proporti le richieste dei clienti della tua zona.
</p>
<div class="form-grid">
<app-form-field
[control]="form.controls.serviceRadiusKm"
label="Raggio di servizio (km)"
inputmode="numeric"
[hint]="'Da ' + radiusMin + ' a ' + radiusMax + ' km.'"
[submitted]="submitted()"
[messages]="{ radius: 'Un numero intero da ' + radiusMin + ' a ' + radiusMax }"
/>
<div></div>
<app-form-field [control]="form.controls.lat" label="Latitudine" inputmode="decimal" [required]="false" [submitted]="submitted()" />
<app-form-field [control]="form.controls.lng" label="Longitudine" inputmode="decimal" [required]="false" [submitted]="submitted()" />
</div>
@if (coordError() && (submitted() || form.controls.lng.touched)) {
<p class="field-error" role="alert">{{ coordError() }}</p>
}
<div class="map-actions">
@if (!mapOpen()) {
<button type="button" class="btn btn--outline btn--small" (click)="mapOpen.set(true)">
{{ location() ? 'Mostra sulla mappa' : 'Scegli il punto sulla mappa' }}
</button>
}
@if (location()) {
<button type="button" class="link-button" (click)="clearLocation()">Rimuovi la posizione</button>
}
</div>
@if (mapOpen()) {
@defer {
<p class="meta">Clicca sulla mappa per impostare la sede; il cerchio mostra la zona coperta dal raggio di servizio.</p>
<app-location-picker [location]="location()" [radiusKm]="radius()" (locationChange)="setLocation($event)" />
} @loading (minimum 200ms) {
<app-loading label="Caricamento della mappa…" />
}
} @else {
<p class="meta map-note">La mappa usa OpenStreetMap e si carica solo quando la apri.</p>
}
</section>
<section class="panel section" aria-labelledby="ip-cert">
<h2 id="ip-cert">Certificazioni</h2>
<p class="meta">Abilitazioni e qualifiche (es. F-GAS, FER, patentino frigorista). Massimo {{ maxCertifications }}.</p>
@if (certifications().length) {
<ul class="chips">
@for (c of certifications(); track $index) {
<li>
<span>{{ c.value }}</span>
<button type="button" (click)="removeCertification($index)" [attr.aria-label]="'Rimuovi ' + c.value">×</button>
</li>
}
</ul>
}
@if (certifications().length < maxCertifications) {
<div class="add">
<label for="new-cert" class="visually-hidden">Nuova certificazione</label>
<input
id="new-cert"
type="text"
[formControl]="newCertification"
maxlength="100"
placeholder="Nuova certificazione"
(keydown.enter)="$event.preventDefault(); addCertification()"
/>
<button type="button" class="btn btn--outline btn--small" (click)="addCertification()">Aggiungi</button>
</div>
}
</section>
<button type="submit" class="btn btn--primary" [disabled]="busy()">{{ busy() ? 'Salvataggio…' : 'Salva il profilo' }}</button>
<a routerLink="/installatori" class="cancel">Torna all'area installatore</a>
</form>
}
@@ -0,0 +1,81 @@
.status {
padding: var(--space-3) var(--space-4);
border-left: 4px solid var(--color-accent);
border-radius: var(--radius-md);
background: var(--color-accent-soft);
&[data-state='approved'] {
border-color: var(--color-success);
background: var(--color-secondary-soft);
}
&[data-state='rejected'] {
border-color: var(--color-danger);
background: var(--color-surface);
}
}
.form-grid {
margin-bottom: var(--space-3);
}
.map-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
margin: var(--space-3) 0;
}
.map-note {
margin: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
li {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding-left: var(--space-3);
border-radius: var(--radius-pill);
background: var(--color-primary-soft);
color: var(--color-primary-strong);
}
button {
min-width: 36px;
min-height: 36px;
border: 0;
border-radius: 50%;
background: transparent;
color: inherit;
font-size: var(--font-size-lg);
cursor: pointer;
}
}
.add {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
input {
flex: 1 1 16rem;
min-height: 44px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-ink-muted);
border-radius: var(--radius-md);
}
}
.cancel {
display: inline-flex;
align-items: center;
min-height: 44px;
margin-left: var(--space-4);
}
@@ -0,0 +1,164 @@
import type { InstallerProfileDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { toApiError } from '../../core/api.service'
import { formatDate } from '../../core/format'
import { InstallerProfileStore } from '../../core/installer-profile.store'
import { applyServerErrors } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
import { LocationPicker } from '../../shared/location-picker'
import { Loading, LoadError } from '../../shared/state'
import {
coordinatesError,
formatCoordinate,
MAX_CERTIFICATIONS,
parseDecimal,
RADIUS_MAX,
RADIUS_MIN,
radiusValidator,
toProfileInput,
} from './profile-form'
const STATUS_TEXT = {
missing: 'Profilo da compilare: compilalo per inviarci la richiesta di approvazione.',
pending: 'Richiesta in valutazione: ti avviseremo via email quando il profilo sarà approvato.',
approved: 'Profilo approvato: hai accesso al listino riservato e alla documentazione tecnica.',
rejected: 'Richiesta non approvata. Per chiarimenti scrivi all’assistenza.',
} as const
/**
* Profilo aziendale dell'installatore: dati, certificazioni, sede su mappa con raggio di servizio. Il salvataggio
* (`PUT /installer/profile`) non cambia lo stato di approvazione, che decide l'admin. Posizione e raggio sono
* quelli usati dalla ricerca degli installatori vicini (`/admin/installers/nearby`, PostGIS).
*/
@Component({
selector: 'app-installer-profile',
imports: [ReactiveFormsModule, RouterLink, FormField, LocationPicker, Loading, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './installer-profile.html',
styleUrl: './installer-profile.scss',
})
export default class InstallerProfile {
protected readonly store = inject(InstallerProfileStore)
protected readonly form = new FormGroup({
businessName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(2), Validators.maxLength(200)] }),
vatNumber: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(20)] }),
phone: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(30)] }),
address: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(300)] }),
serviceRadiusKm: new FormControl('50', { nonNullable: true, validators: [radiusValidator] }),
lat: new FormControl('', { nonNullable: true }),
lng: new FormControl('', { nonNullable: true }),
certifications: new FormArray<FormControl<string>>([]),
})
protected readonly newCertification = new FormControl('', { nonNullable: true, validators: [Validators.maxLength(100)] })
protected readonly submitted = signal(false)
protected readonly busy = signal(false)
protected readonly message = signal<{ ok: boolean; text: string } | null>(null)
protected readonly mapOpen = signal(false)
private readonly values = toSignal(this.form.valueChanges, { initialValue: this.form.getRawValue() })
/** Posizione corrente dai campi (per la mappa), null se assente o non valida. */
protected readonly location = computed(() => {
const v = this.values()
const lat = parseDecimal(v.lat ?? '')
const lng = parseDecimal(v.lng ?? '')
return lat !== null && lng !== null && !coordinatesError(v.lat ?? '', v.lng ?? '') ? { lat, lng } : null
})
protected readonly radius = computed(() => {
const n = Number(this.values().serviceRadiusKm)
return Number.isInteger(n) && n >= RADIUS_MIN && n <= RADIUS_MAX ? n : 50
})
protected readonly coordError = computed(() => {
const v = this.values()
return coordinatesError(v.lat ?? '', v.lng ?? '')
})
protected readonly statusText = computed(() => {
const state = this.store.state()
return state ? STATUS_TEXT[state] : ''
})
protected readonly radiusMin = RADIUS_MIN
protected readonly radiusMax = RADIUS_MAX
protected readonly maxCertifications = MAX_CERTIFICATIONS
protected readonly date = formatDate
constructor() {
inject(Title).setTitle('Profilo aziendale | Area riservata ITI TECHNOLOGY')
// Form compilato dal profilo salvato (una volta caricato e dopo ogni salvataggio)
effect(() => {
const p = this.store.profile()
if (p !== undefined) this.fill(p)
})
}
protected certifications(): FormControl<string>[] {
return this.form.controls.certifications.controls
}
protected addCertification(): void {
const value = this.newCertification.value.trim()
const list = this.form.controls.certifications
if (!value || this.newCertification.invalid || list.length >= MAX_CERTIFICATIONS) return
if (!list.value.some((c) => c.toLowerCase() === value.toLowerCase())) list.push(new FormControl(value, { nonNullable: true }))
this.newCertification.reset()
}
protected removeCertification(index: number): void {
this.form.controls.certifications.removeAt(index)
}
protected setLocation(point: { lat: number; lng: number }): void {
this.form.patchValue({ lat: formatCoordinate(point.lat), lng: formatCoordinate(point.lng) })
this.form.markAsDirty()
}
protected clearLocation(): void {
this.form.patchValue({ lat: '', lng: '' })
this.form.markAsDirty()
}
protected async save(): Promise<void> {
this.submitted.set(true)
this.message.set(null)
if (this.form.invalid || this.coordError() || this.busy()) {
this.message.set({ ok: false, text: 'Controlla i campi evidenziati.' })
return
}
this.busy.set(true)
try {
const saved = await this.store.save(toProfileInput(this.form.getRawValue()))
this.submitted.set(false)
this.message.set({
ok: true,
text: saved.status === 'approved' ? 'Profilo salvato.' : 'Profilo salvato: lo valuteremo al più presto.',
})
} catch (err) {
const error = toApiError(err)
const onFields = error.code === 'VALIDATION_ERROR' && applyServerErrors(this.form, error, { location: 'lat' })
this.message.set({ ok: false, text: onFields ? 'Controlla i campi evidenziati.' : error.message })
} finally {
this.busy.set(false)
}
}
private fill(p: InstallerProfileDto | null): void {
const list = this.form.controls.certifications
list.clear()
for (const c of p?.certifications ?? []) list.push(new FormControl(c, { nonNullable: true }))
this.form.patchValue({
businessName: p?.businessName ?? '',
vatNumber: p?.vatNumber ?? '',
phone: p?.phone ?? '',
address: p?.address ?? '',
serviceRadiusKm: String(p?.serviceRadiusKm ?? 50),
lat: p?.location ? formatCoordinate(p.location.lat) : '',
lng: p?.location ? formatCoordinate(p.location.lng) : '',
})
this.form.markAsPristine()
}
}
@@ -0,0 +1,205 @@
import type { DocumentDto, InstallerProfileDto, UserDto } from '@iti/shared'
import { provideHttpClient, withInterceptors } from '@angular/common/http'
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { provideRouter } from '@angular/router'
import { APP_CONFIG } from '../../core/app-config'
import { authInterceptor } from '../../core/auth.interceptor'
import { AuthStore } from '../../core/auth.store'
import { missingProfileFields } from '../../core/installer-profile.store'
import PriceList, { discountPercent } from './price-list'
import { coordinatesError, formatCoordinate, parseDecimal, toProfileInput } from './profile-form'
import TechnicalDocs, { categoriesOf } from './technical-docs'
const API = 'http://api.test'
const tick = () => new Promise((resolve) => setTimeout(resolve))
const profile = (over: Partial<InstallerProfileDto> = {}): InstallerProfileDto => ({
userId: 'u-inst',
businessName: 'Rossi Impianti',
vatNumber: '12345678903',
phone: '02 1234567',
address: 'Via Roma 1, 20900 Monza (MB)',
location: { lat: 45.5845, lng: 9.2744 },
serviceRadiusKm: 50,
certifications: ['F-GAS'],
status: 'approved',
approvedAt: '2026-10-01T10:00:00.000Z',
...over,
})
describe('area installatori: funzioni', () => {
it('dati mancanti del profilo', () => {
expect(missingProfileFields(profile())).toEqual([])
expect(missingProfileFields(profile({ phone: null, location: null, certifications: [] }))).toEqual([
'telefono',
'posizione sulla mappa',
'certificazioni',
])
expect(missingProfileFields(null)).toContain('ragione sociale')
})
it('coordinate: formato italiano, entrambe o nessuna, solo in Italia', () => {
expect(parseDecimal('45,5845')).toBe(45.5845)
expect(parseDecimal('9.27')).toBe(9.27)
expect(parseDecimal('abc')).toBeNull()
expect(coordinatesError('', '')).toBeNull()
expect(coordinatesError('45,58', '9,27')).toBeNull()
expect(coordinatesError('45,58', '')).toMatch(/latitudine e longitudine/)
expect(coordinatesError('48,85', '2,35')).toBe('La posizione deve essere in Italia') // Parigi
expect(formatCoordinate(45.58451)).toBe('45,58451')
})
it('corpo di PUT /installer/profile', () => {
expect(
toProfileInput({
businessName: ' Rossi Impianti ',
vatNumber: '',
phone: ' 02 1234567 ',
address: '',
serviceRadiusKm: '40',
lat: '45,5845',
lng: '9,2744',
certifications: ['F-GAS', ' F-GAS ', '', 'FER'],
}),
).toEqual({
businessName: 'Rossi Impianti',
vatNumber: null,
phone: '02 1234567',
address: null,
location: { lat: 45.5845, lng: 9.2744 },
serviceRadiusKm: 40,
certifications: ['F-GAS', 'FER'],
})
expect(toProfileInput({ businessName: 'X', vatNumber: '', phone: '', address: '', serviceRadiusKm: '1', lat: '', lng: '', certifications: [] }).location).toBeNull()
})
it('sconto e categorie', () => {
expect(discountPercent(465000, 549000)).toBe(15)
expect(discountPercent(549000, 549000)).toBeNull()
expect(discountPercent(null, 549000)).toBeNull()
const doc = (category: string | null) => ({ category }) as DocumentDto
expect(categoriesOf([doc('Manuali'), doc('CAD'), doc(null), doc('Manuali'), doc(' ')])).toEqual(['CAD', 'Manuali'])
})
})
describe('listino e documentazione: solo per installatori approvati', () => {
let http: HttpTestingController
const user: UserDto = {
id: 'u-inst',
email: 'inst@example.com',
role: 'installer',
firstName: 'Ada',
lastName: 'Rossi',
companyName: 'Rossi Impianti',
vatNumber: null,
emailVerified: true,
createdAt: '2026-01-01T00:00:00.000Z',
}
async function setup(status: InstallerProfileDto['status']) {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
provideRouter([]),
{ provide: APP_CONFIG, useValue: { apiUrl: API, siteUrl: 'http://sito.test', medusaAdminUrl: 'http://medusa.test/app' } },
],
})
http = TestBed.inject(HttpTestingController)
TestBed.inject(AuthStore).applySession({ accessToken: 'tok', expiresIn: 900, user })
}
async function flushProfile(status: InstallerProfileDto['status']) {
await tick()
http.expectOne(`${API}/installer/profile`).flush(profile({ status, approvedAt: status === 'approved' ? '2026-10-01T10:00:00.000Z' : null }))
await tick()
}
afterEach(() => http.verify())
it('in attesa: niente richiesta del listino, avviso con invito a completare il profilo', async () => {
await setup('pending')
const fixture = TestBed.createComponent(PriceList)
fixture.detectChanges()
await flushProfile('pending')
await fixture.whenStable()
http.expectNone((req) => req.url.startsWith(`${API}/shop/products`))
const text = (fixture.nativeElement as HTMLElement).textContent ?? ''
expect(text).toContain("sarà disponibile dopo l'approvazione")
expect(text).not.toContain('Tuo prezzo')
})
it('in attesa: niente richiesta dei documenti tecnici', async () => {
await setup('pending')
const fixture = TestBed.createComponent(TechnicalDocs)
fixture.detectChanges()
await flushProfile('pending')
await fixture.whenStable()
http.expectNone((req) => req.url.startsWith(`${API}/documents`))
expect((fixture.nativeElement as HTMLElement).textContent).toContain('La documentazione tecnica sarà disponibile')
})
it('approvato: listino con il token, prezzo riservato e di listino a confronto', async () => {
await setup('approved')
const fixture = TestBed.createComponent(PriceList)
fixture.detectChanges()
await flushProfile('approved')
await tick()
const req = http.expectOne((r) => r.url === `${API}/shop/products`)
expect(req.request.headers.get('Authorization')).toBe('Bearer tok')
req.flush({
items: [
{
handle: 'pompa-di-calore-aria-acqua-8kw',
title: 'Pompa di calore 8 kW',
subtitle: null,
thumbnail: null,
fromPrice: 465000,
fromOriginalPrice: 549000,
priceType: 'installer',
categories: [],
},
],
total: 1,
limit: 20,
offset: 0,
})
await fixture.whenStable()
const text = ((fixture.nativeElement as HTMLElement).textContent ?? '').replace(/\s+/g, ' ')
expect(text).toContain('5.490 €')
expect(text).toContain('4.650 €')
expect(text).toContain('−15%')
const link = (fixture.nativeElement as HTMLElement).querySelector<HTMLAnchorElement>('td a')
expect(link?.href).toBe('http://sito.test/shop/pompa-di-calore-aria-acqua-8kw')
})
it('approvato: solo documenti installer, filtro per categoria', async () => {
await setup('approved')
const fixture = TestBed.createComponent(TechnicalDocs)
fixture.componentRef.setInput('categoria', 'CAD')
fixture.detectChanges()
await flushProfile('approved')
await tick()
const doc = (id: string, category: string, visibility: DocumentDto['visibility']): DocumentDto => ({
id,
title: `Doc ${id}`,
category,
mimeType: 'application/pdf',
sizeBytes: 100,
visibility,
productRef: null,
createdAt: '2026-10-01T10:00:00.000Z',
})
http.expectOne((r) => r.url === `${API}/documents`).flush({
items: [doc('1', 'Manuali', 'installer'), doc('2', 'CAD', 'installer'), doc('3', 'CAD', 'customer')],
total: 3,
limit: 100,
offset: 0,
})
await fixture.whenStable()
const titles = [...(fixture.nativeElement as HTMLElement).querySelectorAll('.item__title')].map((e) => e.textContent?.trim())
expect(titles).toEqual(['Doc 2'])
})
})
@@ -0,0 +1,228 @@
import type { Paginated, ShopProductSummaryDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { FormControl, ReactiveFormsModule } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { Router } from '@angular/router'
import { ApiService } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { formatEuro } from '../../core/format'
import { InstallerProfileStore } from '../../core/installer-profile.store'
import { Pager, pageFromQuery } from '../../shared/pager'
import { Empty, Loading, LoadError } from '../../shared/state'
import { ApprovalGate } from './approval-gate'
const PAGE_SIZE = 20
/** Sconto percentuale del prezzo riservato sul listino pubblico (arrotondato), null se non c'è sconto. */
export function discountPercent(price: number | null, original: number | null): number | null {
if (price === null || original === null || original <= 0 || price >= original) return null
return Math.round(((original - price) / original) * 100)
}
/**
* Listino riservato: prodotti dello shop con prezzo installatore e prezzo di listino a confronto
* (`GET /shop/products` con il token: l'API applica il listino solo agli installatori approvati). Solo per
* installatori approvati; si ordina dallo shop del sito, dove il carrello dell'account ha già i prezzi riservati.
*/
@Component({
selector: 'app-price-list',
imports: [ReactiveFormsModule, Pager, Loading, Empty, LoadError, ApprovalGate],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page-head">
<h1>Listino riservato</h1>
@if (store.approved()) {
<a class="btn btn--outline btn--small" [href]="shopUrl">Vai allo shop</a>
}
</div>
@if (store.approved()) {
<p class="lead">Prezzi IVA inclusa. Accedendo allo shop con questo account, il carrello applica già i prezzi riservati.</p>
<form class="search" role="search" (submit)="$event.preventDefault(); search()">
<label for="pl-q" class="visually-hidden">Cerca un prodotto</label>
<input id="pl-q" type="search" [formControl]="query" maxlength="100" placeholder="Cerca un prodotto" />
<button type="submit" class="btn btn--outline btn--small">Cerca</button>
</form>
@if (res.isLoading()) {
<app-loading label="Caricamento del listino…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (res.value(); as data) {
@if (data.items.length) {
<div class="table-wrap">
<table>
<caption class="visually-hidden">Prodotti con prezzo riservato e prezzo di listino</caption>
<thead>
<tr>
<th scope="col">Prodotto</th>
<th scope="col" class="num">Listino</th>
<th scope="col" class="num">Tuo prezzo</th>
<th scope="col" class="num">Sconto</th>
<th scope="col"><span class="visually-hidden">Azioni</span></th>
</tr>
</thead>
<tbody>
@for (p of data.items; track p.handle) {
<tr>
<th scope="row">
<span class="product">
@if (p.thumbnail) {
<img [src]="p.thumbnail" alt="" width="40" height="40" loading="lazy" />
}
<span>
{{ p.title }}
@if (p.subtitle) {
<span class="meta">{{ p.subtitle }}</span>
}
</span>
</span>
</th>
<td class="num" [class.strike]="p.priceType === 'installer'">
{{ p.fromOriginalPrice === null ? '—' : euro(p.fromOriginalPrice) }}
</td>
<td class="num price">
@if (p.fromPrice === null) {
—
} @else {
{{ euro(p.fromPrice) }}
@if (p.priceType !== 'installer') {
<span class="meta">(listino)</span>
}
}
</td>
<td class="num">
@if (discount(p); as d) {
−{{ d }}%
} @else {
—
}
</td>
<td><a [href]="shopUrl + '/' + p.handle">Ordina<span class="visually-hidden"> {{ p.title }}</span></a></td>
</tr>
}
</tbody>
</table>
</div>
<p class="meta">Per i prodotti con più varianti è indicato il prezzo più basso; il dettaglio è nella scheda dello shop.</p>
<app-pager [page]="page()" [total]="data.total" [pageSize]="pageSize" />
} @else {
<app-empty [message]="q() ? 'Nessun prodotto trovato per «' + q() + '».' : 'Il catalogo è vuoto.'" />
}
}
} @else {
<app-approval-gate what="Il listino riservato" />
}
`,
styles: `
.search {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-4);
input {
flex: 1 1 16rem;
min-height: 44px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-ink-muted);
border-radius: var(--radius-md);
}
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: var(--space-3);
border-bottom: 1px solid var(--color-line);
text-align: left;
vertical-align: middle;
}
thead th {
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
tbody th {
font-weight: var(--font-weight-semibold);
}
.product {
display: flex;
align-items: center;
gap: var(--space-3);
img {
flex: none;
width: 40px;
height: 40px;
object-fit: cover;
border-radius: var(--radius-sm);
}
.meta {
display: block;
font-weight: var(--font-weight-regular);
}
}
.num {
text-align: right;
white-space: nowrap;
}
.strike {
text-decoration: line-through;
color: var(--color-ink-muted);
}
.price {
font-weight: var(--font-weight-bold);
}
td a {
display: inline-flex;
align-items: center;
min-height: 44px;
}
`,
})
export default class PriceList {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
protected readonly store = inject(InstallerProfileStore)
protected readonly shopUrl = `${inject(APP_CONFIG).siteUrl}/shop`
readonly pagina = input<string>()
readonly q = input<string>()
protected readonly pageSize = PAGE_SIZE
protected readonly page = computed(() => pageFromQuery(this.pagina()))
protected readonly query = new FormControl('', { nonNullable: true })
/** Si carica solo da approvati: per gli altri l'API darebbe comunque i prezzi pubblici. */
protected readonly res = rxResource({
params: () => (this.store.approved() ? { page: this.page(), q: this.q()?.trim() ?? '' } : undefined),
stream: ({ params }) =>
this.api.get<Paginated<ShopProductSummaryDto>>('/shop/products', {
query: { limit: PAGE_SIZE, offset: (params.page - 1) * PAGE_SIZE, q: params.q },
}),
})
protected readonly euro = formatEuro
protected discount(p: ShopProductSummaryDto): number | null {
return p.priceType === 'installer' ? discountPercent(p.fromPrice, p.fromOriginalPrice) : null
}
constructor() {
inject(Title).setTitle('Listino riservato | Area riservata ITI TECHNOLOGY')
effect(() => this.query.setValue(this.q() ?? '', { emitEvent: false }))
}
protected search(): void {
const q = this.query.value.trim()
void this.router.navigate([], { queryParams: { q: q || null, pagina: null } })
}
}
@@ -0,0 +1,68 @@
import { isInItaly } from '@iti/shared'
import type { AbstractControl, ValidationErrors } from '@angular/forms'
import type { InstallerProfileInput } from '../../core/installer-profile.store'
/** Valori grezzi del form del profilo installatore (tutti testo, come nei campi). */
export interface InstallerFormValue {
businessName: string
vatNumber: string
phone: string
address: string
serviceRadiusKm: string
lat: string
lng: string
certifications: string[]
}
export const RADIUS_MIN = 1
export const RADIUS_MAX = 300
export const MAX_CERTIFICATIONS = 20
/** Numero decimale scritto all'italiana o all'inglese ("45,58" o "45.58"); null se vuoto o non valido. */
export function parseDecimal(text: string): number | null {
const t = text.trim().replace(',', '.')
if (!/^-?\d+(\.\d+)?$/.test(t)) return null
return Number(t)
}
/** Raggio di servizio: intero tra 1 e 300 km. */
export function radiusValidator(control: AbstractControl): ValidationErrors | null {
const t = String(control.value ?? '').trim()
if (!/^\d+$/.test(t)) return { radius: true }
const n = Number(t)
return n >= RADIUS_MIN && n <= RADIUS_MAX ? null : { radius: true }
}
/**
* Coordinate: entrambe vuote (nessuna posizione) oppure entrambe numeri dentro l'Italia.
* Validatore del gruppo, con l'errore riportato sul campo latitudine.
*/
export function coordinatesError(lat: string, lng: string): string | null {
if (lat.trim() === '' && lng.trim() === '') return null
const la = parseDecimal(lat)
const ln = parseDecimal(lng)
if (la === null || ln === null) return 'Inserisci latitudine e longitudine (es. 45,5845 e 9,2744), oppure scegli il punto sulla mappa'
if (!isInItaly({ lat: la, lng: ln })) return 'La posizione deve essere in Italia'
return null
}
/** Corpo di `PUT /installer/profile` dai valori del form. */
export function toProfileInput(v: InstallerFormValue): InstallerProfileInput {
const text = (s: string) => (s.trim() === '' ? null : s.trim())
const lat = parseDecimal(v.lat)
const lng = parseDecimal(v.lng)
return {
businessName: v.businessName.trim(),
vatNumber: text(v.vatNumber),
phone: text(v.phone),
address: text(v.address),
location: lat !== null && lng !== null ? { lat, lng } : null,
serviceRadiusKm: Number(v.serviceRadiusKm.trim()),
certifications: [...new Set(v.certifications.map((c) => c.trim()).filter(Boolean))],
}
}
/** Coordinate da mostrare nei campi ("45.58451" → "45,58451"). */
export function formatCoordinate(value: number): string {
return String(value).replace('.', ',')
}
@@ -0,0 +1,152 @@
import type { DocumentDto, Paginated } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { map } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { DownloadService, fileName } from '../../core/download.service'
import { formatBytes, formatDate } from '../../core/format'
import { InstallerProfileStore } from '../../core/installer-profile.store'
import { Empty, Loading, LoadError } from '../../shared/state'
import { fileKind } from '../customer/documents'
import { ApprovalGate } from './approval-gate'
/** Categorie presenti nei documenti (alfabetiche, senza duplicati né vuote). */
export function categoriesOf(docs: DocumentDto[]): string[] {
return [...new Set(docs.map((d) => d.category?.trim()).filter((c): c is string => Boolean(c)))].sort((a, b) => a.localeCompare(b, 'it'))
}
/**
* Documentazione tecnica: i documenti con visibilità `installer` (manuali, CAD, certificazioni, listini PDF…),
* filtrabili per categoria con `?categoria=`. Le categorie sono quelle assegnate dall'admin ai documenti.
* Solo per installatori approvati: l'API non li restituisce agli altri. Quelli per tutti i clienti stanno in
* `/documenti`.
*/
@Component({
selector: 'app-technical-docs',
imports: [RouterLink, Loading, Empty, LoadError, ApprovalGate],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Documentazione tecnica</h1>
@if (store.approved()) {
@if (res.isLoading()) {
<app-loading label="Caricamento dei documenti…" />
} @else if (res.error()) {
<app-load-error [error]="res.error()" (retry)="res.reload()" />
} @else if (docs().length) {
@if (categories().length > 1) {
<nav class="filters" aria-label="Filtra per categoria">
<a [routerLink]="[]" [queryParams]="{ categoria: null }" [attr.aria-current]="!category() ? 'page' : null">Tutte</a>
@for (c of categories(); track c) {
<a [routerLink]="[]" [queryParams]="{ categoria: c }" [attr.aria-current]="category() === c ? 'page' : null">{{ c }}</a>
}
</nav>
}
@if (downloadError(); as message) {
<p class="alert alert--error" role="alert">{{ message }}</p>
}
@if (visible().length) {
<ul class="item-list">
@for (d of visible(); track d.id) {
<li class="item">
<span class="item__main">
<span class="item__title">{{ d.title }}</span>
<span class="meta">
{{ d.category ?? 'Senza categoria' }} · {{ kind(d.mimeType) }} · {{ bytes(d.sizeBytes) }} · {{ date(d.createdAt) }}
@if (d.productRef) {
· prodotto {{ d.productRef }}
}
</span>
</span>
<button type="button" class="btn btn--outline btn--small" [disabled]="downloading() === d.id" (click)="download(d)">
{{ downloading() === d.id ? 'Download…' : 'Scarica' }}<span class="visually-hidden"> {{ d.title }}</span>
</button>
</li>
}
</ul>
} @else {
<app-empty message="Nessun documento in questa categoria." />
}
} @else {
<app-empty message="Non ci sono ancora documenti tecnici riservati agli installatori." />
}
} @else {
<app-approval-gate what="La documentazione tecnica" />
}
`,
styles: `
.filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-4);
a {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-ink);
text-decoration: none;
&[aria-current='page'] {
border-color: var(--color-primary);
background: var(--color-primary-soft);
color: var(--color-primary-strong);
font-weight: var(--font-weight-semibold);
}
}
}
`,
})
export default class TechnicalDocs {
private readonly api = inject(ApiService)
private readonly downloads = inject(DownloadService)
protected readonly store = inject(InstallerProfileStore)
readonly categoria = input<string>()
protected readonly res = rxResource({
params: () => (this.store.approved() ? true : undefined),
stream: () =>
this.api
.get<Paginated<DocumentDto>>('/documents', { query: { limit: 100 } })
.pipe(map((page) => page.items.filter((d) => d.visibility === 'installer'))),
})
protected readonly docs = computed(() => this.res.value() ?? [])
protected readonly categories = computed(() => categoriesOf(this.docs()))
protected readonly category = computed(() => {
const c = this.categoria()
return c && this.categories().includes(c) ? c : null
})
protected readonly visible = computed(() => {
const c = this.category()
return c ? this.docs().filter((d) => d.category?.trim() === c) : this.docs()
})
protected readonly downloading = signal<string | null>(null)
protected readonly downloadError = signal<string | null>(null)
protected readonly kind = fileKind
protected readonly bytes = formatBytes
protected readonly date = formatDate
constructor() {
inject(Title).setTitle('Documentazione tecnica | Area riservata ITI TECHNOLOGY')
}
protected async download(doc: DocumentDto): Promise<void> {
this.downloadError.set(null)
this.downloading.set(doc.id)
try {
await this.downloads.download(`/documents/${doc.id}/download`, fileName(doc.title, doc.mimeType))
} catch (err) {
this.downloadError.set(`Download di "${doc.title}" non riuscito: ${toApiError(err).message}`)
} finally {
this.downloading.set(null)
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import { AuthStore } from '../core/auth.store'
import { homeFor } from '../core/roles'
/** Pagina inesistente (dentro il layout autenticato). */
@Component({
selector: 'app-not-found',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Pagina non trovata</h1>
<p>L'indirizzo non corrisponde a nessuna pagina dell'area riservata.</p>
<a [routerLink]="home()" class="btn btn--primary">Torna alla tua area</a>
`,
})
export default class NotFound {
private readonly auth = inject(AuthStore)
protected readonly home = computed(() => {
const role = this.auth.role()
return role ? homeFor(role) : '/login'
})
constructor() {
inject(Title).setTitle('Pagina non trovata | Area riservata ITI TECHNOLOGY')
}
}
+41
View File
@@ -0,0 +1,41 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import type { StatusLabel } from '../core/labels'
/** Badge di stato (ordine, preventivo, ticket): testo sempre visibile, il colore è solo un rinforzo. */
@Component({
selector: 'app-badge',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ status().label }}`,
host: { class: 'badge', '[attr.data-tone]': 'status().tone' },
styles: `
:host {
display: inline-block;
padding: 0.15rem var(--space-3);
border-radius: var(--radius-pill);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
white-space: nowrap;
background: var(--color-surface-alt);
color: var(--color-ink);
}
:host([data-tone='info']) {
background: var(--color-primary-soft);
color: var(--color-primary-strong);
}
:host([data-tone='success']) {
background: var(--color-secondary-soft);
color: var(--color-success);
}
:host([data-tone='warning']) {
background: var(--color-accent-soft);
color: var(--color-warning);
}
:host([data-tone='danger']) {
background: #fdecea;
color: var(--color-danger);
}
`,
})
export class Badge {
readonly status = input.required<StatusLabel>()
}
@@ -0,0 +1,95 @@
import { ChangeDetectionStrategy, Component, type ElementRef, input, output, viewChild } from '@angular/core'
let nextId = 0
/**
* Dialogo di conferma modale (`<dialog>` nativo: focus intrappolato, Esc chiude, sfondo inerte).
* Si apre con `open()`; emette `confirmed` o `cancelled`. Il contenuto proiettato va sotto il messaggio
* (es. campi aggiuntivi come la password).
*/
@Component({
selector: 'app-confirm-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<dialog #dialog [attr.aria-labelledby]="id + '-title'" (cancel)="onEscape($event)" (close)="closed()">
<h2 [id]="id + '-title'">{{ title() }}</h2>
<p>{{ message() }}</p>
<ng-content />
<div class="actions">
<button type="button" class="btn btn--outline" (click)="cancel()" [disabled]="busy()">Annulla</button>
<button type="button" class="btn" [class.btn--primary]="!danger()" [class.btn--danger]="danger()" [disabled]="busy()" (click)="confirmed.emit()">
{{ busy() ? 'Attendi…' : confirmLabel() }}
</button>
</div>
</dialog>
`,
styles: `
dialog {
width: min(520px, calc(100vw - 2 * var(--space-4)));
padding: var(--space-5);
border: 0;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
&::backdrop {
background: rgb(18 33 43 / 45%);
}
}
h2 {
font-size: var(--font-size-xl);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
margin-top: var(--space-5);
}
.btn--danger {
background: var(--color-danger);
color: #fff;
}
`,
})
export class ConfirmDialog {
readonly title = input.required<string>()
readonly message = input.required<string>()
readonly confirmLabel = input('Conferma')
readonly danger = input(false)
readonly busy = input(false)
readonly confirmed = output<void>()
readonly cancelled = output<void>()
protected readonly id = `dialog-${++nextId}`
private readonly dialog = viewChild.required<ElementRef<HTMLDialogElement>>('dialog')
private cancelling = false
open(): void {
const el = this.dialog().nativeElement
if (!el.open) el.showModal()
}
close(): void {
const el = this.dialog().nativeElement
if (el.open) el.close()
}
/** Esc: chiude come "Annulla", ma non mentre l'operazione è in corso. */
protected onEscape(event: Event): void {
if (this.busy()) {
event.preventDefault()
return
}
this.cancelling = true
}
protected cancel(): void {
this.cancelling = true
this.close()
}
protected closed(): void {
if (this.cancelling) this.cancelled.emit()
this.cancelling = false
}
}
@@ -0,0 +1,31 @@
import type { FormGroup } from '@angular/forms'
import type { ApiError } from '../core/api.service'
/**
* Riporta sui campi gli errori di validazione dell'API (`details[].path` = `body.<campo>`), come errore
* `{ server: messaggio }` (mostrato da FormField). Restituisce true se almeno un errore è finito su un campo.
*/
export function applyServerErrors(form: FormGroup, error: ApiError, aliases: Record<string, string> = {}): boolean {
let applied = false
for (const detail of error.details) {
const field = detail.path.replace(/^body\./, '')
const control = form.get(aliases[field] ?? field)
if (!control) continue
control.setErrors({ server: detail.message })
control.markAsTouched()
applied = true
}
return applied
}
/** Messaggio per gli errori più comuni delle pagine di autenticazione. */
export function authErrorMessage(error: ApiError): string {
switch (error.code) {
case 'RATE_LIMITED':
return 'Troppi tentativi ravvicinati: attendi un minuto e riprova.'
case 'NETWORK_ERROR':
return 'Servizio non raggiungibile: controlla la connessione e riprova.'
default:
return error.message
}
}
+113
View File
@@ -0,0 +1,113 @@
import { ChangeDetectionStrategy, Component, computed, effect, input, signal } from '@angular/core'
import { type AbstractControl, FormControl, ReactiveFormsModule, type ValidationErrors } from '@angular/forms'
let nextId = 0
/** Messaggio in italiano per il primo errore del controllo. */
export function fieldErrorMessage(errors: ValidationErrors | null, custom: Record<string, string> = {}): string | null {
if (!errors) return null
const [key, value] = Object.entries(errors)[0] ?? []
if (!key) return null
if (custom[key]) return custom[key]
switch (key) {
case 'required':
return 'Campo obbligatorio'
case 'email':
return 'Indirizzo email non valido'
case 'minlength':
return `Almeno ${(value as { requiredLength: number }).requiredLength} caratteri`
case 'maxlength':
return `Massimo ${(value as { requiredLength: number }).requiredLength} caratteri`
case 'server':
return String(value)
default:
return 'Valore non valido'
}
}
/**
* Campo di un form reattivo: etichetta, input (o textarea con `rows`), suggerimento ed errore collegati con aria-describedby.
* L'errore compare dopo che il campo è stato lasciato (touched) o dopo un tentativo di invio (`submitted`).
* Gli errori arrivati dall'API vanno impostati come `{ server: messaggio }`.
*/
@Component({
selector: 'app-form-field',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label [for]="id">
{{ label() }}
@if (!required()) {
<span class="optional">(facoltativo)</span>
}
</label>
@if (hint()) {
<p class="hint" [id]="id + '-hint'">{{ hint() }}</p>
}
@if (rows()) {
<textarea
[id]="id"
[rows]="rows() ?? 4"
[formControl]="control()"
[attr.maxlength]="maxlength()"
[attr.required]="required() ? '' : null"
[attr.aria-invalid]="error() ? 'true' : null"
[attr.aria-describedby]="describedBy()"
></textarea>
} @else {
<input
[id]="id"
[type]="type()"
[formControl]="control()"
[attr.autocomplete]="autocomplete()"
[attr.inputmode]="inputmode()"
[attr.maxlength]="maxlength()"
[attr.required]="required() ? '' : null"
[attr.aria-invalid]="error() ? 'true' : null"
[attr.aria-describedby]="describedBy()"
/>
}
@if (error(); as message) {
<p class="error" [id]="id + '-error'">{{ message }}</p>
}
`,
host: { class: 'form-field' },
})
export class FormField {
readonly control = input.required<FormControl<string>>()
readonly label = input.required<string>()
readonly type = input<'text' | 'email' | 'tel' | 'password'>('text')
readonly autocomplete = input<string | null>(null)
readonly inputmode = input<string | null>(null)
readonly maxlength = input<number | null>(null)
/** Se impostato, area di testo con quel numero di righe invece di un input. */
readonly rows = input<number | null>(null)
readonly hint = input<string | null>(null)
readonly required = input(true)
readonly submitted = input(false)
/** Messaggi per errori specifici (es. `{ pattern: 'CAP di 5 cifre' }`). */
readonly messages = input<Record<string, string>>({})
protected readonly id = `field-${++nextId}`
/** Incrementato a ogni evento del controllo (valore, stato, touched): rende reattivo l'errore. */
private readonly version = signal(0)
protected readonly error = computed(() => {
this.version()
const control: AbstractControl = this.control()
if (!control.invalid || !(control.touched || this.submitted())) return null
return fieldErrorMessage(control.errors, this.messages())
})
protected readonly describedBy = computed(() => {
const ids = [this.hint() ? `${this.id}-hint` : null, this.error() ? `${this.id}-error` : null].filter(Boolean)
return ids.length > 0 ? ids.join(' ') : null
})
constructor() {
effect((onCleanup) => {
const sub = this.control().events.subscribe(() => this.version.update((v) => v + 1))
onCleanup(() => sub.unsubscribe())
})
}
}
+4
View File
@@ -0,0 +1,4 @@
// Build ESM di Leaflet (il pacchetto "leaflet" punta alla build UMD/CommonJS): stessi tipi di @types/leaflet
declare module 'leaflet/dist/leaflet-src.esm.js' {
export * from 'leaflet'
}
+128
View File
@@ -0,0 +1,128 @@
import { ChangeDetectionStrategy, Component, effect, inject, input } from '@angular/core'
import { FormControl, ReactiveFormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router'
let nextId = 0
export interface FilterOption {
/** Valore del parametro di query; null = nessun filtro ("Tutti"). */
value: string | null
label: string
}
/**
* Filtro a pillole fatto di link (`?<param>=valore`, torna a pagina 1): refresh, back/forward e link
* condivisi mostrano lo stesso elenco.
*/
@Component({
selector: 'app-filter-pills',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<nav class="pills" [attr.aria-label]="label()">
@for (o of options(); track o.label) {
<a
[routerLink]="[]"
[queryParams]="query(o.value)"
queryParamsHandling="merge"
[attr.aria-current]="(current() ?? null) === o.value ? 'page' : null"
>{{ o.label }}</a
>
}
</nav>
`,
styles: `
.pills {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
a {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-ink);
text-decoration: none;
&[aria-current='page'] {
border-color: var(--color-primary);
background: var(--color-primary-soft);
color: var(--color-primary-strong);
font-weight: var(--font-weight-semibold);
}
}
`,
})
export class FilterPills {
readonly param = input.required<string>()
readonly options = input.required<FilterOption[]>()
readonly current = input<string | null | undefined>(null)
readonly label = input('Filtra')
protected query(value: string | null) {
return { [this.param()]: value, pagina: null }
}
}
/** Ricerca testuale: all'invio aggiorna `?q=` (pagina 1). Il campo segue l'URL (back/forward). */
@Component({
selector: 'app-search-box',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form class="search" role="search" (submit)="$event.preventDefault(); search()">
<label [for]="id">{{ label() }}</label>
<div class="row">
<input [id]="id" type="search" [formControl]="text" maxlength="100" [attr.placeholder]="placeholder()" />
<button type="submit" class="btn btn--outline btn--small">Cerca</button>
</div>
</form>
`,
styles: `
.search {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.row {
display: flex;
gap: var(--space-2);
}
input {
min-width: 0;
width: 16rem;
max-width: 100%;
min-height: 40px;
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-ink-muted);
border-radius: var(--radius-md);
font: inherit;
}
`,
})
export class SearchBox {
private readonly router = inject(Router)
readonly value = input<string | undefined>()
readonly label = input('Cerca')
readonly placeholder = input<string | null>(null)
protected readonly id = `search-${++nextId}`
protected readonly text = new FormControl('', { nonNullable: true })
constructor() {
effect(() => this.text.setValue(this.value() ?? ''))
}
protected search(): void {
const q = this.text.value.trim()
void this.router.navigate([], { queryParams: { q: q || null, pagina: null }, queryParamsHandling: 'merge' })
}
}
@@ -0,0 +1,148 @@
import { isInItaly } from '@iti/shared'
import { DOCUMENT } from '@angular/common'
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
DestroyRef,
type ElementRef,
effect,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core'
import type { Circle, CircleMarker, Map as LeafletMap } from 'leaflet'
/** CSS di Leaflet copiato negli asset da scripts/vendor-assets.mjs. */
const LEAFLET_CSS = 'vendor/leaflet/leaflet.css'
/** Centro e zoom con cui si apre la mappa senza una posizione: l'Italia intera. */
const ITALY_CENTER: [number, number] = [42.2, 12.5]
const COLOR = '#0b5c7a'
/**
* Selettore della sede su mappa OpenStreetMap: un clic imposta il punto, il cerchio mostra il raggio di
* servizio. Il punto deve stare in Italia (stessi confini dell'API, `ITALY_BOUNDS` di @iti/shared).
*
* Va caricato su richiesta (`@defer` dopo un clic), come la mappa del sito pubblico: Leaflet e le tile di
* tile.openstreetmap.org partono solo quando l'utente apre la mappa. Per chi non usa il mouse il profilo ha anche
* i campi latitudine/longitudine, sincronizzati con la mappa.
* ⚠️ CSP (prompt 34): `img-src https://tile.openstreetmap.org`.
*/
@Component({
selector: 'app-location-picker',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div #host class="map" role="application" aria-label="Mappa: clicca per impostare la posizione della sede"></div>
@if (outside()) {
<p class="field-error" role="alert">Il punto scelto è fuori dall'Italia: scegli la posizione della sede.</p>
}
`,
styles: `
.map {
width: 100%;
height: 380px;
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
z-index: 0;
cursor: crosshair;
}
p {
margin: var(--space-2) 0 0;
}
`,
})
export class LocationPicker {
readonly location = input<{ lat: number; lng: number } | null>(null)
readonly radiusKm = input.required<number>()
readonly locationChange = output<{ lat: number; lng: number }>()
protected readonly outside = signal(false)
private readonly document = inject(DOCUMENT)
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host')
private L: typeof import('leaflet') | null = null
private map: LeafletMap | null = null
private marker: CircleMarker | null = null
private circle: Circle | null = null
private lastRadius = 0
private destroyed = false
constructor() {
afterNextRender(() => void this.create())
inject(DestroyRef).onDestroy(() => {
this.destroyed = true
this.map?.remove()
})
// Posizione o raggio cambiati (clic, campi del form): marcatore e cerchio seguono
effect(() => this.sync(this.location(), this.radiusKm()))
}
private async create(): Promise<void> {
const [L] = await Promise.all([import('leaflet/dist/leaflet-src.esm.js'), this.loadStyles()])
if (this.destroyed) return
this.L = L as unknown as typeof import('leaflet')
const start = this.location()
this.map = L.map(this.host().nativeElement, { scrollWheelZoom: false }).setView(
start ? [start.lat, start.lng] : ITALY_CENTER,
start ? 10 : 6,
)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(this.map)
this.map.on('click', (e) => {
const point = { lat: round(e.latlng.lat), lng: round(e.latlng.lng) }
const inside = isInItaly(point)
this.outside.set(!inside)
if (inside) this.locationChange.emit(point)
})
this.sync(this.location(), this.radiusKm())
}
private sync(location: { lat: number; lng: number } | null, radiusKm: number): void {
const { L, map } = this
if (!L || !map) return
if (!location) {
this.marker?.remove()
this.circle?.remove()
this.marker = this.circle = null
return
}
const position: [number, number] = [location.lat, location.lng]
const radius = Math.max(1, radiusKm || 0) * 1000
if (this.marker && this.circle) {
this.marker.setLatLng(position)
this.circle.setLatLng(position).setRadius(radius)
} else {
this.circle = L.circle(position, { radius, color: COLOR, weight: 2, fillColor: COLOR, fillOpacity: 0.12 }).addTo(map)
this.marker = L.circleMarker(position, { radius: 8, color: COLOR, weight: 3, fillColor: COLOR, fillOpacity: 0.8 }).addTo(map)
}
// Inquadra tutta l'area servita quando cambia il raggio (o al primo punto), non a ogni clic
if (radius !== this.lastRadius) {
this.lastRadius = radius
map.fitBounds(this.circle.getBounds(), { padding: [16, 16] })
}
}
/** Aggiunge (una volta) il CSS di Leaflet e attende che sia caricato. */
private loadStyles(): Promise<void> {
const existing = this.document.querySelector<HTMLLinkElement>(`link[href="${LEAFLET_CSS}"]`)
if (existing?.dataset['loaded']) return Promise.resolve()
return new Promise((resolve) => {
const link = existing ?? this.document.createElement('link')
link.addEventListener('load', () => ((link.dataset['loaded'] = '1'), resolve()), { once: true })
link.addEventListener('error', () => resolve(), { once: true })
if (!existing) {
link.rel = 'stylesheet'
link.href = LEAFLET_CSS
this.document.head.appendChild(link)
}
})
}
}
/** 5 decimali ≈ 1 metro: abbastanza per una sede, senza precisione finta. */
function round(value: number): number {
return Math.round(value * 1e5) / 1e5
}
+51
View File
@@ -0,0 +1,51 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'
import { RouterLink } from '@angular/router'
/** Pagina corrente da `?pagina=` (1 se assente o non valida). */
export function pageFromQuery(value: string | undefined): number {
const n = Number(value)
return Number.isInteger(n) && n >= 1 ? n : 1
}
/** Paginazione "Precedente / Successiva" con link veri (`?pagina=n`): back/forward e refresh funzionano. */
@Component({
selector: 'app-pager',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (pages() > 1) {
<nav class="pager" aria-label="Paginazione">
@if (page() > 1) {
<a [routerLink]="[]" [queryParams]="{ pagina: page() - 1 === 1 ? null : page() - 1 }" queryParamsHandling="merge"
>← Precedente</a
>
}
<span>Pagina {{ page() }} di {{ pages() }}</span>
@if (page() < pages()) {
<a [routerLink]="[]" [queryParams]="{ pagina: page() + 1 }" queryParamsHandling="merge">Successiva →</a>
}
</nav>
}
`,
styles: `
.pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--space-4);
margin-top: var(--space-5);
}
a {
display: inline-flex;
align-items: center;
min-height: 44px;
}
`,
})
export class Pager {
readonly page = input.required<number>()
readonly total = input.required<number>()
readonly pageSize = input.required<number>()
protected readonly pages = computed(() => Math.max(1, Math.ceil(this.total() / this.pageSize())))
}
+103
View File
@@ -0,0 +1,103 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'
import { ApiError } from '../core/api.service'
/** Messaggio per l'utente da un errore di caricamento. */
export function loadErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
if (error.code === 'SHOP_UNAVAILABLE' || error.status === 503) return 'Il servizio non è disponibile in questo momento.'
if (error.code === 'NETWORK_ERROR') return 'Impossibile contattare il server: controlla la connessione.'
return error.message
}
return 'Si è verificato un errore imprevisto.'
}
/** Caricamento in corso (annunciato agli screen reader). */
@Component({
selector: 'app-loading',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p class="state" role="status"><span class="spinner" aria-hidden="true"></span>{{ label() }}</p>`,
styles: `
.state {
display: flex;
align-items: center;
gap: var(--space-3);
margin: 0;
padding: var(--space-4) 0;
color: var(--color-ink-muted);
}
.spinner {
width: 1.1rem;
height: 1.1rem;
border: 2px solid var(--color-line);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 2.5s;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`,
})
export class Loading {
readonly label = input('Caricamento…')
}
/** Nessun elemento da mostrare, con un testo e un'azione facoltativa (contenuto proiettato). */
@Component({
selector: 'app-empty',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="empty">
<p>{{ message() }}</p>
<ng-content />
</div>
`,
styles: `
.empty {
padding: var(--space-5);
border: 1px dashed var(--color-line);
border-radius: var(--radius-md);
text-align: center;
color: var(--color-ink-muted);
p:last-child {
margin: 0;
}
}
`,
})
export class Empty {
readonly message = input.required<string>()
}
/** Errore di caricamento con "Riprova". */
@Component({
selector: 'app-load-error',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="alert alert--error" role="alert">
<p>{{ message() }}</p>
<button type="button" class="btn btn--outline" (click)="retry.emit()">Riprova</button>
</div>
`,
styles: `
p {
margin-bottom: var(--space-3);
}
.btn {
min-height: 40px;
}
`,
})
export class LoadError {
readonly error = input<unknown>()
readonly retry = output<void>()
protected message = () => loadErrorMessage(this.error())
}
@@ -0,0 +1,12 @@
/**
* Valori di default (sviluppo locale). In produzione vengono sovrascritti da `/config.json`, letto all'avvio
* (vedi core/app-config.ts): la stessa build vale per qualunque dominio.
*/
export const environment = {
/** URL dell'API Fastify visto dal browser, senza slash finale. */
apiUrl: 'http://localhost:3000',
/** Sito pubblico (apps/public): link "Torna al sito", privacy, redirect dopo il login. */
siteUrl: 'http://localhost:4200',
/** Dashboard admin di Medusa (catalogo, prezzi, listini, ordini): link dal pannello admin. */
medusaAdminUrl: 'http://localhost:9000/app',
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>Area riservata | ITI TECHNOLOGY</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#0b5c7a">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="icon" href="favicon.ico" sizes="32x32">
</head>
<body>
<app-root></app-root>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
import { bootstrapApplication } from '@angular/platform-browser'
import { App } from './app/app'
import { appConfig } from './app/app.config'
import { loadRuntimeConfig } from './app/core/app-config'
loadRuntimeConfig()
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err: unknown) => console.error(err))
+263
View File
@@ -0,0 +1,263 @@
// Stili globali dell'area riservata: token, mixin e base sono gli stessi del sito pubblico (packages/styles).
@use 'base';
@use 'mixins' as mq;
body {
background: var(--color-surface-alt);
}
h1 {
font-size: var(--font-size-3xl);
}
// Pannello bianco (form, riquadri)
.panel {
padding: var(--space-5);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
@include mq.up(md) {
padding: var(--space-6);
}
}
.btn--block {
width: 100%;
}
.btn[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
// Pulsante che sembra un link
.link-button {
padding: 0;
border: 0;
background: none;
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 0.15em;
cursor: pointer;
}
// --- Pagine dell'area riservata ---
// Intestazione: titolo + azioni
.page-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-5);
h1 {
margin: 0;
}
}
.back-link {
display: inline-flex;
align-items: center;
min-height: 44px;
margin-bottom: var(--space-2);
}
.section {
margin-bottom: var(--space-6);
h2 {
font-size: var(--font-size-xl);
}
}
// Elenco di elementi cliccabili (ordini, preventivi, ticket, documenti)
.item-list {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.item {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2) var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface);
&__main {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
&__title {
font-weight: var(--font-weight-semibold);
overflow-wrap: anywhere;
}
&__side {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
}
// Link che rende cliccabile tutta la riga
a.item {
color: inherit;
text-decoration: none;
&:hover {
border-color: var(--color-primary);
}
@include mq.focus-ring;
}
.meta {
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
.amount {
font-weight: var(--font-weight-bold);
white-space: nowrap;
}
// Coppie etichetta/valore
.facts {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-2) var(--space-4);
margin: 0;
dt {
color: var(--color-ink-muted);
}
dd {
margin: 0;
}
}
.btn--small {
min-height: 40px;
padding: var(--space-1) var(--space-4);
}
.btn--danger {
background: var(--color-danger);
color: #fff;
&:hover {
color: #fff;
filter: brightness(0.92);
}
}
// --- Pannello admin ---
// Tabella dei dati: scorre in orizzontale dentro il suo contenitore sugli schermi stretti
.table-wrap {
overflow-x: auto;
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
th,
td {
padding: var(--space-3);
border-bottom: 1px solid var(--color-line);
text-align: left;
vertical-align: top;
}
th {
background: var(--color-surface-alt);
font-weight: var(--font-weight-semibold);
white-space: nowrap;
}
tbody tr:last-child td {
border-bottom: 0;
}
tbody tr:hover {
background: var(--color-surface-alt);
}
.num {
text-align: right;
white-space: nowrap;
}
a {
font-weight: var(--font-weight-semibold);
}
}
// Barra dei filtri sopra un elenco: pillole + ricerca
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
// Riga di pulsanti
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
// Campo select o input compatto inline (cambio stato)
.inline-field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
label {
font-weight: var(--font-weight-semibold);
}
select,
input {
min-height: 40px;
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-ink-muted);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-ink);
font: inherit;
}
}
// Griglia a due colonne da desktop (dettaglio + colonna laterale)
.split {
display: grid;
gap: var(--space-5);
@include mq.up(lg) {
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
align-items: start;
}
}
// Testo lungo inserito dall'utente (messaggi, note)
.pre-line {
white-space: pre-line;
overflow-wrap: anywhere;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}
+35
View File
@@ -0,0 +1,35 @@
/* Estende la base del monorepo (strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes) con le opzioni per Angular. */
{
"extends": "../../tsconfig.base.json",
"compileOnSave": false,
"compilerOptions": {
"lib": ["ES2022", "ES2023.Intl", "dom", "dom.iterable"],
"module": "preserve",
"moduleResolution": "bundler",
"declaration": false,
"declarationMap": false,
"noPropertyAccessFromIndexSignature": true,
"experimentalDecorators": true,
"importHelpers": true,
"outDir": "./out-tsc"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true,
"typeCheckHostBindings": true,
"extendedDiagnostics": {
"defaultCategory": "error"
}
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+14
View File
@@ -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": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}