Primo rilascio
This commit is contained in:
37
frontend/nursery-app/src/app/app.component.html
Normal file
37
frontend/nursery-app/src/app/app.component.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<mat-sidenav-container class="sidenav-container">
|
||||
<mat-sidenav #drawer class="sidenav" fixedInViewport
|
||||
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
|
||||
[mode]="(isHandset$ | async) ? 'over' : 'side'"
|
||||
[opened]="(isHandset$ | async) === false">
|
||||
<mat-toolbar>Menu</mat-toolbar>
|
||||
<mat-nav-list>
|
||||
@for (item of menuItems; track item.route) {
|
||||
<a mat-list-item [routerLink]="item.route" (click)="toggleDrawer()">
|
||||
<mat-icon matListItemIcon>{{ item.icon }}</mat-icon>
|
||||
<span>{{ item.name }}</span>
|
||||
</a>
|
||||
}
|
||||
</mat-nav-list>
|
||||
</mat-sidenav>
|
||||
<mat-sidenav-content>
|
||||
<mat-toolbar color="primary">
|
||||
@if (isHandset$ | async) {
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle sidenav"
|
||||
mat-icon-button
|
||||
(click)="drawer.toggle()">
|
||||
<mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<span>{{ title }}</span>
|
||||
<span class="spacer"></span> <!-- Spaziatore per allineare a destra -->
|
||||
@if (authService.isAuthenticated$ | async) {
|
||||
<button mat-button (click)="authService.logout()">Logout</button>
|
||||
}
|
||||
</mat-toolbar>
|
||||
<main class="main-content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
21
frontend/nursery-app/src/app/app.component.scss
Normal file
21
frontend/nursery-app/src/app/app.component.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
.sidenav-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidenav {
|
||||
width: 200px; // Larghezza della sidebar
|
||||
}
|
||||
|
||||
.sidenav .mat-toolbar {
|
||||
background: inherit; // Usa lo sfondo del tema per la toolbar della sidenav
|
||||
}
|
||||
|
||||
.mat-toolbar.mat-primary {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000; // Assicura che la toolbar principale sia sopra gli altri elementi
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px; // Aggiunge un po' di spazio intorno al contenuto principale
|
||||
}
|
||||
29
frontend/nursery-app/src/app/app.component.spec.ts
Normal file
29
frontend/nursery-app/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have the 'nursery-app' title`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('nursery-app');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, nursery-app');
|
||||
});
|
||||
});
|
||||
64
frontend/nursery-app/src/app/app.component.ts
Normal file
64
frontend/nursery-app/src/app/app.component.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Component, ViewChild } from '@angular/core';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, shareReplay } from 'rxjs/operators';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AuthService } from './services/auth.service'; // Importa AuthService
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
MatSidenavModule,
|
||||
MatListModule,
|
||||
MatToolbarModule,
|
||||
MatIconModule,
|
||||
MatButtonModule
|
||||
],
|
||||
standalone: true, // Assicurati che sia standalone
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'Nursery Management';
|
||||
|
||||
@ViewChild('drawer') drawer!: MatSidenav;
|
||||
|
||||
isHandset$: Observable<boolean>;
|
||||
|
||||
|
||||
menuItems = [
|
||||
{ name: 'Insegnanti', icon: 'school', route: '/teachers' },
|
||||
{ name: 'Anni Scolastici', icon: 'calendar_today', route: '/school-years' },
|
||||
{ name: 'Contratti Insegnanti', icon: 'description', route: '/contracts' },
|
||||
{ name: 'Strutture', icon: 'business', route: '/structures' },
|
||||
{ name: 'Bambini', icon: 'child_care', route: '/children' },
|
||||
{ name: 'Definizione Turni', icon: 'schedule', route: '/shifts' }
|
||||
];
|
||||
|
||||
constructor(
|
||||
private breakpointObserver: BreakpointObserver,
|
||||
public authService: AuthService
|
||||
) {
|
||||
this.isHandset$ = this.breakpointObserver.observe(Breakpoints.Handset)
|
||||
.pipe(
|
||||
map(result => result.matches),
|
||||
shareReplay()
|
||||
);
|
||||
}
|
||||
|
||||
toggleDrawer(): void {
|
||||
this.isHandset$.subscribe(isHandset => {
|
||||
if (isHandset) {
|
||||
this.drawer.toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
22
frontend/nursery-app/src/app/app.config.ts
Normal file
22
frontend/nursery-app/src/app/app.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { HTTP_INTERCEPTORS, provideHttpClient } from '@angular/common/http';
|
||||
import { provideNativeDateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; // Importa anche MAT_DATE_LOCALE
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { AuthInterceptor } from './interceptors/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideNativeDateAdapter(),
|
||||
{ provide: MAT_DATE_LOCALE, useValue: 'it-IT' }, // Imposta la locale italiana
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: AuthInterceptor,
|
||||
multi: true
|
||||
}
|
||||
]
|
||||
};
|
||||
24
frontend/nursery-app/src/app/app.routes.ts
Normal file
24
frontend/nursery-app/src/app/app.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { TeachersComponent } from './components/teachers/teachers.component';
|
||||
import { SchoolYearsComponent } from './components/school-years/school-years.component';
|
||||
import { ContractsComponent } from './components/contracts/contracts.component';
|
||||
import { StructuresComponent } from './components/structures/structures.component';
|
||||
import { ChildrenComponent } from './components/children/children.component';
|
||||
import { ShiftsComponent } from './components/shifts/shifts.component';
|
||||
import { LoginComponent } from './components/login/login.component';
|
||||
import { authGuard } from './guards/auth.guard'; // Importa la guardia
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'login', component: LoginComponent }, // Rotta per il login
|
||||
// Route protette
|
||||
{ path: 'teachers', component: TeachersComponent, canActivate: [authGuard] },
|
||||
{ path: 'school-years', component: SchoolYearsComponent, canActivate: [authGuard] },
|
||||
{ path: 'contracts', component: ContractsComponent, canActivate: [authGuard] },
|
||||
{ path: 'structures', component: StructuresComponent, canActivate: [authGuard] },
|
||||
{ path: 'children', component: ChildrenComponent, canActivate: [authGuard] },
|
||||
{ path: 'shifts', component: ShiftsComponent, canActivate: [authGuard] },
|
||||
// Redirect alla prima pagina (es. teachers) o a una dashboard se loggato
|
||||
// Redirect alla prima pagina protetta (es. teachers) o a login se non autenticato
|
||||
{ path: '', redirectTo: '/teachers', pathMatch: 'full' },
|
||||
// TODO: Aggiungere una rotta wildcard (**) per pagina 404
|
||||
];
|
||||
@@ -0,0 +1,115 @@
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
|
||||
<mat-dialog-content [formGroup]="childForm">
|
||||
<!-- Indicatore caricamento per le select -->
|
||||
<div *ngIf="isLoadingSelects" style="text-align: center; padding: 20px;">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<p>Caricamento strutture...</p>
|
||||
</div>
|
||||
|
||||
<div class="form-grid" *ngIf="!isLoadingSelects">
|
||||
|
||||
<h3>Dati Anagrafici</h3>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Nome</mat-label>
|
||||
<input matInput formControlName="first_name" required>
|
||||
<mat-error *ngIf="childForm.get('first_name')?.hasError('required')">Nome obbligatorio.</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Cognome</mat-label>
|
||||
<input matInput formControlName="last_name" required>
|
||||
<mat-error *ngIf="childForm.get('last_name')?.hasError('required')">Cognome obbligatorio.</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Data di Nascita</mat-label>
|
||||
<input matInput [matDatepicker]="dobPicker" formControlName="date_of_birth" required readonly>
|
||||
<mat-hint>GG/MM/AAAA</mat-hint>
|
||||
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dobPicker></mat-datepicker>
|
||||
<mat-error *ngIf="childForm.get('date_of_birth')?.hasError('required')">Data di nascita obbligatoria.</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Data Iscrizione</mat-label>
|
||||
<input matInput [matDatepicker]="enrollmentPicker" formControlName="enrollment_date" required readonly>
|
||||
<mat-hint>GG/MM/AAAA</mat-hint>
|
||||
<mat-datepicker-toggle matSuffix [for]="enrollmentPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #enrollmentPicker></mat-datepicker>
|
||||
<mat-error *ngIf="childForm.get('enrollment_date')?.hasError('required')">Data iscrizione obbligatoria.</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Struttura (Opzionale)</mat-label>
|
||||
<mat-select formControlName="structure_id">
|
||||
<mat-option [value]="null">-- Nessuna --</mat-option>
|
||||
<mat-option *ngFor="let structure of (structures$ | async)" [value]="structure.id">
|
||||
{{ structure.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="full-width-field">
|
||||
<mat-checkbox formControlName="is_active" labelPosition="after">
|
||||
Iscrizione Attiva
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<h3 class="full-width-field">Contatti Genitori/Tutori</h3>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Genitore 1 - Nome Cognome</mat-label>
|
||||
<input matInput formControlName="parent1_name">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Genitore 2 - Nome Cognome</mat-label>
|
||||
<input matInput formControlName="parent2_name">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Genitore 1 - Telefono</mat-label>
|
||||
<input matInput formControlName="parent1_phone">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Genitore 2 - Telefono</mat-label>
|
||||
<input matInput formControlName="parent2_phone">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Genitore 1 - Email</mat-label>
|
||||
<input matInput formControlName="parent1_email" type="email">
|
||||
<mat-error *ngIf="childForm.get('parent1_email')?.hasError('email')">Email non valida.</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Genitore 2 - Email</mat-label>
|
||||
<input matInput formControlName="parent2_email" type="email">
|
||||
<mat-error *ngIf="childForm.get('parent2_email')?.hasError('email')">Email non valida.</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<h3 class="full-width-field">Residenza</h3>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Indirizzo</mat-label>
|
||||
<input matInput formControlName="address">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Città</mat-label>
|
||||
<input matInput formControlName="city">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill" class="full-width-field">
|
||||
<mat-label>Note (Allergie, ecc.)</mat-label>
|
||||
<textarea matInput formControlName="notes" rows="3"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Annulla</button>
|
||||
<button mat-raised-button color="primary" (click)="onSave()" [disabled]="!childForm.valid">
|
||||
{{ isEditMode ? 'Salva Modifiche' : 'Aggiungi' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,58 @@
|
||||
/* Contenitore per la griglia */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr; /* Default: una colonna */
|
||||
gap: 0 16px; /* Spazio tra colonne */
|
||||
}
|
||||
|
||||
/* Campi a larghezza piena */
|
||||
.form-grid .full-width-field {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Titoli separatori */
|
||||
.form-grid h3 {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.12);
|
||||
padding-bottom: 5px;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-grid h3:first-of-type {
|
||||
margin-top: 0; /* Rimuovi margine sopra il primo titolo */
|
||||
}
|
||||
|
||||
|
||||
/* Stile per checkbox */
|
||||
.form-grid mat-checkbox {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Media Query per schermi più larghi */
|
||||
@media (min-width: 600px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr 1fr; /* Due colonne */
|
||||
}
|
||||
/* Fai occupare tutta la larghezza ai campi che devono stare da soli */
|
||||
.form-grid mat-form-field:has(textarea),
|
||||
.form-grid > div.full-width-field, /* Div con checkbox */
|
||||
.form-grid h3 /* Titoli separatori */
|
||||
{
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stili generali per i campi */
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Contenuto dialog scrollabile */
|
||||
mat-dialog-content {
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Child } from '../../services/child.service'; // Importa interfaccia Child
|
||||
import { Structure, StructureService } from '../../services/structure.service'; // Per select struttura
|
||||
|
||||
@Component({
|
||||
selector: 'app-child-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatProgressSpinnerModule
|
||||
],
|
||||
templateUrl: './child-dialog.component.html',
|
||||
styleUrl: './child-dialog.component.scss'
|
||||
})
|
||||
export class ChildDialogComponent implements OnInit {
|
||||
childForm: FormGroup;
|
||||
isEditMode: boolean;
|
||||
title: string;
|
||||
|
||||
structures$: Observable<Structure[]> | undefined;
|
||||
isLoadingSelects = false;
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<ChildDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: { child?: Child },
|
||||
private fb: FormBuilder,
|
||||
private structureService: StructureService // Per caricare le strutture
|
||||
) {
|
||||
this.isEditMode = !!data?.child;
|
||||
this.title = this.isEditMode ? 'Modifica Dati Bambino' : 'Aggiungi Nuovo Bambino';
|
||||
|
||||
this.childForm = this.fb.group({
|
||||
first_name: ['', Validators.required],
|
||||
last_name: ['', Validators.required],
|
||||
date_of_birth: [null, Validators.required],
|
||||
enrollment_date: [new Date(), Validators.required], // Default a oggi
|
||||
structure_id: [null], // Opzionale
|
||||
parent1_name: [''],
|
||||
parent1_phone: [''],
|
||||
parent1_email: ['', Validators.email],
|
||||
parent2_name: [''],
|
||||
parent2_phone: [''],
|
||||
parent2_email: ['', Validators.email],
|
||||
address: [''],
|
||||
city: [''],
|
||||
notes: [''],
|
||||
is_active: [true] // Default a true
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStructures(); // Carica strutture per la select
|
||||
|
||||
if (this.isEditMode && this.data.child) {
|
||||
const dob = this.data.child.date_of_birth ? new Date(this.data.child.date_of_birth) : null;
|
||||
const enrollmentDate = this.data.child.enrollment_date ? new Date(this.data.child.enrollment_date) : null;
|
||||
|
||||
this.childForm.patchValue({
|
||||
...this.data.child,
|
||||
date_of_birth: dob,
|
||||
enrollment_date: enrollmentDate
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadStructures(): void {
|
||||
this.isLoadingSelects = true;
|
||||
this.structures$ = this.structureService.getStructures();
|
||||
this.structures$.subscribe({
|
||||
next: () => this.isLoadingSelects = false,
|
||||
error: (err) => {
|
||||
console.error("Error loading structures for child dialog:", err);
|
||||
this.isLoadingSelects = false;
|
||||
// Non chiudiamo il dialog, ma potremmo mostrare un errore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (this.childForm.valid) {
|
||||
const formData = { ...this.childForm.value };
|
||||
|
||||
// Formatta le date
|
||||
if (formData.date_of_birth instanceof Date) {
|
||||
formData.date_of_birth = `${formData.date_of_birth.getFullYear()}-${(formData.date_of_birth.getMonth()+1).toString().padStart(2, "0")}-${formData.date_of_birth.getDate()}`
|
||||
}
|
||||
if (formData.enrollment_date instanceof Date) {
|
||||
formData.enrollment_date = `${formData.enrollment_date.getFullYear()}-${(formData.enrollment_date.getMonth()+1).toString().padStart(2, "0")}-${formData.enrollment_date.getDate()}`
|
||||
}
|
||||
|
||||
// Assicura null per campi opzionali vuoti
|
||||
const fieldsToNullCheck = ['structure_id', 'parent1_name', 'parent1_phone', 'parent1_email', 'parent2_name', 'parent2_phone', 'parent2_email', 'address', 'city', 'notes'];
|
||||
fieldsToNullCheck.forEach(field => {
|
||||
if (formData[field] === '' || formData[field] === undefined) {
|
||||
formData[field] = null;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.dialogRef.close(formData);
|
||||
} else {
|
||||
console.log('Child Form Invalid:', this.childForm.errors);
|
||||
Object.keys(this.childForm.controls).forEach(key => {
|
||||
const control = this.childForm.get(key);
|
||||
if (control && control.errors) {
|
||||
console.log(`Control Error - ${key}:`, control.errors);
|
||||
}
|
||||
});
|
||||
this.childForm.markAllAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<h2>Gestione Bambini</h2>
|
||||
|
||||
<div class="actions-header">
|
||||
<!-- TODO: Aggiungere filtri per struttura, stato attivo -->
|
||||
<button mat-raised-button color="primary" (click)="addChild()">
|
||||
<mat-icon>child_care</mat-icon> Aggiungi Bambino
|
||||
</button>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Struttura</mat-label>
|
||||
<mat-select [(ngModel)]="structureIdFilter" (ngModelChange)="loadChildren()">
|
||||
<mat-option [value]="null">Tutte</mat-option>
|
||||
<mat-option *ngFor="let structure of structures" [value]="structure.id">
|
||||
{{structure.name}}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-slide-toggle
|
||||
[(ngModel)]="isActiveFilter"
|
||||
(change)="loadChildren()"
|
||||
labelPosition="before">
|
||||
Mostra solo attivi
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
<div class="spinner-container" *ngIf="isLoading">
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && !error">
|
||||
<table mat-table [dataSource]="(children$ | async) ?? []" class="mat-elevation-z8 child-table">
|
||||
|
||||
<!-- Colonna ID -->
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef> ID </th>
|
||||
<td mat-cell *matCellDef="let child"> {{child.id}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Cognome -->
|
||||
<ng-container matColumnDef="last_name">
|
||||
<th mat-header-cell *matHeaderCellDef> Cognome </th>
|
||||
<td mat-cell *matCellDef="let child"> {{child.last_name}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Nome -->
|
||||
<ng-container matColumnDef="first_name">
|
||||
<th mat-header-cell *matHeaderCellDef> Nome </th>
|
||||
<td mat-cell *matCellDef="let child"> {{child.first_name}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Data Nascita -->
|
||||
<ng-container matColumnDef="date_of_birth">
|
||||
<th mat-header-cell *matHeaderCellDef> Data Nascita </th>
|
||||
<td mat-cell *matCellDef="let child"> {{child.date_of_birth | date:'dd/MM/yyyy'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Struttura -->
|
||||
<ng-container matColumnDef="structure_name">
|
||||
<th mat-header-cell *matHeaderCellDef> Struttura </th>
|
||||
<td mat-cell *matCellDef="let child"> {{child.structure_name || '-'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Attivo -->
|
||||
<ng-container matColumnDef="is_active">
|
||||
<th mat-header-cell *matHeaderCellDef> Attivo </th>
|
||||
<td mat-cell *matCellDef="let child">
|
||||
<mat-slide-toggle
|
||||
[checked]="!!child.is_active"
|
||||
(change)="toggleActive(child, $event)"
|
||||
aria-label="Stato attivo bambino">
|
||||
</mat-slide-toggle> <!-- Tag chiuso correttamente -->
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Azioni -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef> Azioni </th>
|
||||
<td mat-cell *matCellDef="let child">
|
||||
<button mat-icon-button color="primary" aria-label="Modifica bambino" (click)="editChild(child)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" aria-label="Elimina bambino" (click)="deleteChild(child)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
|
||||
<!-- Riga da mostrare quando non ci sono dati -->
|
||||
<tr class="mat-row" *matNoDataRow>
|
||||
<td class="mat-cell" [attr.colspan]="displayedColumns.length">
|
||||
Nessun bambino trovato.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
.actions-header {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
/* TODO: Aggiungere spazio per filtri */
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
padding: 10px;
|
||||
border: 1px solid red;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.child-table {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Stile per la riga "Nessun dato" */
|
||||
.mat-row .mat-cell.mat-no-data-row {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
/* Spaziatura tra i pulsanti di azione */
|
||||
td.mat-cell button:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Allineamento verticale per slide-toggle */
|
||||
td.mat-cell mat-slide-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ChildrenComponent } from './children.component';
|
||||
|
||||
describe('ChildrenComponent', () => {
|
||||
let component: ChildrenComponent;
|
||||
let fixture: ComponentFixture<ChildrenComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ChildrenComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ChildrenComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatSlideToggleModule, MatSlideToggleChange } from '@angular/material/slide-toggle'; // Importa MatSlideToggleChange
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatOptionModule } from '@angular/material/core';// Necessario per il toggle se usato con ngModel
|
||||
import { ChildService, Child, ChildInput } from '../../services/child.service';
|
||||
import { Structure, StructureService } from '../../services/structure.service'; // Importa Structure e StructureService
|
||||
import { ConfirmDialogComponent, ConfirmDialogData } from '../confirm-dialog/confirm-dialog.component';
|
||||
import { ChildDialogComponent } from '../child-dialog/child-dialog.component'; // Importa il dialog
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-children',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
MatSlideToggleModule,
|
||||
DatePipe,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatOptionModule
|
||||
],
|
||||
templateUrl: './children.component.html',
|
||||
styleUrl: './children.component.scss'
|
||||
})
|
||||
export class ChildrenComponent implements OnInit {
|
||||
|
||||
structures: any[] = [];
|
||||
|
||||
children$: Observable<Child[]> | undefined;
|
||||
// Colonne per la tabella bambini
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'last_name',
|
||||
'first_name',
|
||||
'date_of_birth',
|
||||
'structure_name', // Nome struttura associata
|
||||
'is_active',
|
||||
'actions'
|
||||
];
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
structureIdFilter: number | null = null;
|
||||
isActiveFilter: boolean | null = true; // Mostra solo attivi di default
|
||||
|
||||
constructor(
|
||||
private childService: ChildService,
|
||||
private dialog: MatDialog,
|
||||
private structureService: StructureService // Inietta StructureService
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStructures(); // Carica le strutture
|
||||
this.loadChildren(); // Carica i bambini (con filtri)
|
||||
}
|
||||
|
||||
// Metodo per caricare le strutture per il filtro
|
||||
loadStructures(): void {
|
||||
// Non impostiamo isLoading = true qui per non interferire con lo spinner principale
|
||||
this.structureService.getStructures().subscribe({
|
||||
next: (structures: Structure[]) => { // Specifica il tipo
|
||||
this.structures = structures;
|
||||
},
|
||||
error: (err: any) => { // Specifica il tipo (o un tipo di errore più specifico se noto)
|
||||
console.error('Error loading structures for filter:', err);
|
||||
// Potresti voler mostrare un errore specifico per il caricamento dei filtri
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadChildren(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
// TODO: Aggiungere filtri UI per structure_id e is_active
|
||||
const filters: any = {};
|
||||
if (this.structureIdFilter !== null && this.structureIdFilter !== undefined) {
|
||||
filters.structure_id = this.structureIdFilter;
|
||||
}
|
||||
if (this.isActiveFilter !== null && this.isActiveFilter !== undefined) {
|
||||
filters.is_active = this.isActiveFilter;
|
||||
}
|
||||
this.children$ = this.childService.getChildren(filters);
|
||||
|
||||
this.children$.subscribe({
|
||||
next: () => this.isLoading = false,
|
||||
error: (err) => {
|
||||
console.error('Error loading children:', err);
|
||||
this.error = 'Errore durante il caricamento dei bambini.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
const dialogRef = this.dialog.open(ChildDialogComponent, {
|
||||
width: '80vw', // Dialog più largo per più campi
|
||||
maxWidth: '80vw',
|
||||
data: {}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.childService.addChild(result).subscribe({
|
||||
next: () => this.loadChildren(),
|
||||
error: (err) => {
|
||||
console.error('Error adding child:', err);
|
||||
this.error = err.error?.error || 'Errore durante l\'aggiunta del bambino.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editChild(child: Child): void {
|
||||
// Recupera dati completi prima di aprire
|
||||
this.childService.getChild(child.id).subscribe({
|
||||
next: (fullChildData) => {
|
||||
const dialogRef = this.dialog.open(ChildDialogComponent, {
|
||||
width: '80vw',
|
||||
maxWidth: '80vw',
|
||||
data: { child: fullChildData }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.childService.updateChild(child.id, result).subscribe({
|
||||
next: () => this.loadChildren(),
|
||||
error: (err) => {
|
||||
console.error(`Error updating child ${child.id}:`, err);
|
||||
this.error = err.error?.error || 'Errore durante la modifica del bambino.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error fetching full child data for ID ${child.id}:`, err);
|
||||
this.error = 'Errore nel recuperare i dati completi per la modifica.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteChild(child: Child): void {
|
||||
const dialogData: ConfirmDialogData = {
|
||||
title: 'Conferma Eliminazione Bambino',
|
||||
message: `Sei sicuro di voler eliminare ${child.first_name} ${child.last_name} (ID: ${child.id})?`,
|
||||
confirmButtonText: 'Elimina'
|
||||
};
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
width: '400px',
|
||||
data: dialogData
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.isLoading = true;
|
||||
this.childService.deleteChild(child.id).subscribe({
|
||||
next: () => this.loadChildren(),
|
||||
error: (err) => {
|
||||
console.error(`Error deleting child ${child.id}:`, err);
|
||||
this.error = 'Errore durante l\'eliminazione del bambino.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// Gestisce il cambio di stato attivo/inattivo
|
||||
toggleActive(child: Child, event: MatSlideToggleChange): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
const updatedChild: ChildInput = { is_active: event.checked };
|
||||
|
||||
this.childService.updateChild(child.id, updatedChild).subscribe({
|
||||
next: () => {
|
||||
console.log(`Successfully updated active state for ${child.id}`);
|
||||
// Potremmo aggiornare solo l'elemento localmente invece di ricaricare tutto
|
||||
// Ma per ora ricarichiamo per semplicità
|
||||
this.loadChildren();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error toggling active state for child ${child.id}:`, err);
|
||||
this.error = 'Errore durante l\'aggiornamento dello stato.';
|
||||
this.isLoading = false;
|
||||
this.loadChildren(); // Ricarica per ripristinare
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<p>{{ message }}</p>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onDismiss()">{{ cancelButtonText }}</button>
|
||||
<button mat-raised-button color="warn" (click)="onConfirm()">{{ confirmButtonText }}</button>
|
||||
<!-- Usiamo color="warn" per il pulsante di conferma di un'azione distruttiva -->
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
// Interfaccia per i dati passati al dialog
|
||||
export interface ConfirmDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule
|
||||
],
|
||||
templateUrl: './confirm-dialog.component.html',
|
||||
styleUrl: './confirm-dialog.component.scss'
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
|
||||
// Valori di default se non specificati
|
||||
title: string;
|
||||
message: string;
|
||||
confirmButtonText: string;
|
||||
cancelButtonText: string;
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<ConfirmDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData
|
||||
) {
|
||||
// Assegna i valori dai dati passati o usa i default
|
||||
this.title = data.title;
|
||||
this.message = data.message;
|
||||
this.confirmButtonText = data.confirmButtonText || 'Conferma';
|
||||
this.cancelButtonText = data.cancelButtonText || 'Annulla';
|
||||
}
|
||||
|
||||
onConfirm(): void {
|
||||
// Chiude il dialog restituendo true per indicare la conferma
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
|
||||
onDismiss(): void {
|
||||
// Chiude il dialog restituendo false per indicare l'annullamento
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<h2>Gestione Contratti Insegnanti</h2>
|
||||
|
||||
<div class="actions-header">
|
||||
<button mat-raised-button color="primary" (click)="addContract()">
|
||||
<mat-icon>post_add</mat-icon> Aggiungi Contratto
|
||||
</button>
|
||||
<!-- TODO: Aggiungere filtri per insegnante, anno scolastico, struttura -->
|
||||
</div>
|
||||
|
||||
<div class="spinner-container" *ngIf="isLoading">
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && !error">
|
||||
<table mat-table [dataSource]="(contracts$ | async) ?? []" class="mat-elevation-z8 contract-table">
|
||||
|
||||
<!-- Colonna ID -->
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef> ID </th>
|
||||
<td mat-cell *matCellDef="let contract"> {{contract.id}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Nome Insegnante (Combinato) -->
|
||||
<ng-container matColumnDef="teacher_name">
|
||||
<th mat-header-cell *matHeaderCellDef> Insegnante </th>
|
||||
<td mat-cell *matCellDef="let contract"> {{contract.teacher_last_name}} {{contract.teacher_first_name}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Anno Scolastico -->
|
||||
<ng-container matColumnDef="school_year_name">
|
||||
<th mat-header-cell *matHeaderCellDef> Anno Scolastico </th>
|
||||
<td mat-cell *matCellDef="let contract"> {{contract.school_year_name}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Struttura -->
|
||||
<ng-container matColumnDef="structure_name">
|
||||
<th mat-header-cell *matHeaderCellDef> Struttura </th>
|
||||
<td mat-cell *matCellDef="let contract"> {{contract.structure_name || '-'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Tipo Contratto -->
|
||||
<ng-container matColumnDef="contract_type">
|
||||
<th mat-header-cell *matHeaderCellDef> Tipo </th>
|
||||
<td mat-cell *matCellDef="let contract"> {{contract.contract_type || '-'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Data Inizio -->
|
||||
<ng-container matColumnDef="start_date">
|
||||
<th mat-header-cell *matHeaderCellDef> Data Inizio </th>
|
||||
<td mat-cell *matCellDef="let contract"> {{contract.start_date | date:'dd/MM/yyyy'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Data Fine -->
|
||||
<ng-container matColumnDef="end_date">
|
||||
<th mat-header-cell *matHeaderCellDef> Data Fine </th>
|
||||
<td mat-cell *matCellDef="let contract"> {{contract.end_date ? (contract.end_date | date:'dd/MM/yyyy') : 'N/D'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Azioni -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef> Azioni </th>
|
||||
<td mat-cell *matCellDef="let contract">
|
||||
<button mat-icon-button color="primary" aria-label="Modifica contratto" (click)="editContract(contract)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" aria-label="Elimina contratto" (click)="deleteContract(contract)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
|
||||
<!-- Riga da mostrare quando non ci sono dati -->
|
||||
<tr class="mat-row" *matNoDataRow>
|
||||
<td class="mat-cell" [attr.colspan]="displayedColumns.length">
|
||||
Nessun contratto trovato.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
.actions-header {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
/* TODO: Aggiungere spazio per eventuali filtri */
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
padding: 10px;
|
||||
border: 1px solid red;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.contract-table {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Stile per la riga "Nessun dato" */
|
||||
.mat-row .mat-cell.mat-no-data-row {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
/* Spaziatura tra i pulsanti di azione */
|
||||
td.mat-cell button:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ContractsComponent } from './contracts.component';
|
||||
|
||||
describe('ContractsComponent', () => {
|
||||
let component: ContractsComponent;
|
||||
let fixture: ComponentFixture<ContractsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContractsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ContractsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { TeacherContractService, TeacherContract } from '../../services/teacher-contract.service'; // Importa servizio e interfaccia
|
||||
import { ConfirmDialogComponent, ConfirmDialogData } from '../confirm-dialog/confirm-dialog.component';
|
||||
import { TeacherContractDialogComponent } from '../teacher-contract-dialog/teacher-contract-dialog.component'; // Importa il dialog
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contracts',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
DatePipe
|
||||
],
|
||||
templateUrl: './contracts.component.html',
|
||||
styleUrl: './contracts.component.scss'
|
||||
})
|
||||
export class ContractsComponent implements OnInit {
|
||||
|
||||
contracts$: Observable<TeacherContract[]> | undefined;
|
||||
// Colonne per la tabella contratti (includendo nomi joinati)
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'teacher_name', // Nome combinato
|
||||
'school_year_name',
|
||||
'structure_name', // Aggiunto nome struttura
|
||||
'contract_type',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'actions'
|
||||
];
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
|
||||
constructor(
|
||||
private teacherContractService: TeacherContractService,
|
||||
private dialog: MatDialog
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadContracts();
|
||||
}
|
||||
|
||||
loadContracts(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
// Passa eventuali filtri se necessario, per ora carica tutto
|
||||
this.contracts$ = this.teacherContractService.getTeacherContracts();
|
||||
|
||||
this.contracts$.subscribe({
|
||||
next: () => this.isLoading = false,
|
||||
error: (err) => {
|
||||
console.error('Error loading teacher contracts:', err);
|
||||
this.error = 'Errore durante il caricamento dei contratti.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addContract(): void {
|
||||
const dialogRef = this.dialog.open(TeacherContractDialogComponent, {
|
||||
width: '600px', // Adatta larghezza
|
||||
data: {}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.teacherContractService.addTeacherContract(result).subscribe({
|
||||
next: () => this.loadContracts(),
|
||||
error: (err) => {
|
||||
console.error('Error adding contract:', err);
|
||||
// Mostra errore specifico se disponibile (es. duplicate, invalid FK)
|
||||
this.error = err.error?.error || 'Errore durante l\'aggiunta del contratto.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editContract(contract: TeacherContract): void {
|
||||
// Recupera i dati completi del contratto prima di aprire il dialog
|
||||
// perché la tabella potrebbe mostrare solo dati parziali o formattati
|
||||
this.teacherContractService.getTeacherContract(contract.id).subscribe({
|
||||
next: (fullContractData) => {
|
||||
const dialogRef = this.dialog.open(TeacherContractDialogComponent, {
|
||||
width: '600px',
|
||||
data: { contract: fullContractData }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.teacherContractService.updateTeacherContract(contract.id, result).subscribe({
|
||||
next: () => this.loadContracts(),
|
||||
error: (err) => {
|
||||
console.error(`Error updating contract ${contract.id}:`, err);
|
||||
this.error = err.error?.error || 'Errore durante la modifica del contratto.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error fetching full contract data for ID ${contract.id}:`, err);
|
||||
this.error = 'Errore nel recuperare i dati completi per la modifica.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteContract(contract: TeacherContract): void {
|
||||
const dialogData: ConfirmDialogData = {
|
||||
title: 'Conferma Eliminazione Contratto',
|
||||
message: `Sei sicuro di voler eliminare il contratto ID ${contract.id} per ${contract.teacher_first_name} ${contract.teacher_last_name} (${contract.school_year_name})?`,
|
||||
confirmButtonText: 'Elimina'
|
||||
};
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
width: '450px', // Larghezza maggiore per messaggio più lungo
|
||||
data: dialogData
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.isLoading = true;
|
||||
this.teacherContractService.deleteTeacherContract(contract.id).subscribe({
|
||||
next: () => this.loadContracts(),
|
||||
error: (err) => {
|
||||
console.error(`Error deleting contract ${contract.id}:`, err);
|
||||
this.error = 'Errore durante l\'eliminazione del contratto.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="login-container">
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>Login</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput formControlName="email" type="email" required>
|
||||
<mat-error *ngIf="loginForm.get('email')?.hasError('required')">
|
||||
Email obbligatoria.
|
||||
</mat-error>
|
||||
<mat-error *ngIf="loginForm.get('email')?.hasError('email')">
|
||||
Formato email non valido.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Password</mat-label>
|
||||
<input matInput formControlName="password" type="password" required>
|
||||
<mat-error *ngIf="loginForm.get('password')?.hasError('required')">
|
||||
Password obbligatoria.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div *ngIf="errorMessage" class="error-message">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="isLoading || !loginForm.valid">
|
||||
<span *ngIf="!isLoading">Accedi</span>
|
||||
<mat-spinner *ngIf="isLoading" diameter="24" style="margin: 0 auto;"></mat-spinner>
|
||||
</button>
|
||||
|
||||
<!-- Aggiungere link per recupero password se necessario -->
|
||||
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 64px); /* Altezza viewport meno altezza toolbar */
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
mat-card {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
mat-card-content form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router'; // Importa RouterModule
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card'; // Per layout
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { AuthService } from '../../services/auth.service'; // Importa AuthService
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule, // Aggiungi RouterModule
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatProgressSpinnerModule
|
||||
],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrl: './login.component.scss'
|
||||
})
|
||||
export class LoginComponent {
|
||||
loginForm: FormGroup;
|
||||
isLoading = false;
|
||||
errorMessage: string | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private authService: AuthService,
|
||||
private router: Router
|
||||
) {
|
||||
this.loginForm = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.loginForm.valid) {
|
||||
this.isLoading = true;
|
||||
this.errorMessage = null;
|
||||
const credentials = this.loginForm.value;
|
||||
|
||||
this.authService.login(credentials).subscribe({
|
||||
next: (response) => {
|
||||
this.isLoading = false;
|
||||
console.log('Login successful:', response);
|
||||
// Reindirizza alla dashboard o alla prima pagina protetta
|
||||
// Determiniamo la rotta iniziale in base al ruolo? Per ora reindirizziamo alla root.
|
||||
this.router.navigate(['/']); // Reindirizza alla root dopo login
|
||||
},
|
||||
error: (err) => {
|
||||
this.isLoading = false;
|
||||
console.error('Login failed:', err);
|
||||
this.errorMessage = err.error?.error || 'Credenziali non valide o errore server.';
|
||||
this.loginForm.patchValue({ password: '' }); // Resetta campo password
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.loginForm.markAllAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
|
||||
<mat-dialog-content [formGroup]="schoolYearForm">
|
||||
<!-- Mostra errore validatore custom a livello di form -->
|
||||
<mat-error *ngIf="schoolYearForm.hasError('dateRange') && (schoolYearForm.get('start_date')?.touched || schoolYearForm.get('end_date')?.touched)">
|
||||
La data di fine deve essere successiva alla data di inizio.
|
||||
</mat-error>
|
||||
|
||||
<div class="form-grid">
|
||||
|
||||
<mat-form-field appearance="fill" class="full-width-field">
|
||||
<mat-label>Nome Anno Scolastico (es. 2024/2025)</mat-label>
|
||||
<input matInput formControlName="name" required>
|
||||
<mat-error *ngIf="schoolYearForm.get('name')?.hasError('required')">
|
||||
Il nome è obbligatorio.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Data Inizio</mat-label>
|
||||
<input matInput [matDatepicker]="startPicker" formControlName="start_date" required readonly>
|
||||
<mat-hint>GG/MM/AAAA</mat-hint>
|
||||
<mat-datepicker-toggle matSuffix [for]="startPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #startPicker></mat-datepicker>
|
||||
<mat-error *ngIf="schoolYearForm.get('start_date')?.hasError('required')">
|
||||
La data di inizio è obbligatoria.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Data Fine</mat-label>
|
||||
<input matInput [matDatepicker]="endPicker" formControlName="end_date" required readonly>
|
||||
<mat-hint>GG/MM/AAAA</mat-hint>
|
||||
<mat-datepicker-toggle matSuffix [for]="endPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #endPicker></mat-datepicker>
|
||||
<mat-error *ngIf="schoolYearForm.get('end_date')?.hasError('required')">
|
||||
La data di fine è obbligatoria.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="full-width-field">
|
||||
<mat-checkbox formControlName="is_active" labelPosition="after">
|
||||
Anno Scolastico Attivo (Questo disattiverà automaticamente altri anni attivi)
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Annulla</button>
|
||||
<!-- Abilita solo se valido (include validatore custom dateRange) -->
|
||||
<button mat-raised-button color="primary" (click)="onSave()" [disabled]="!schoolYearForm.valid">
|
||||
{{ isEditMode ? 'Salva Modifiche' : 'Aggiungi' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Contenitore per la griglia */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr; /* Default: una colonna */
|
||||
gap: 0 16px; /* Spazio tra colonne */
|
||||
}
|
||||
|
||||
/* Campi a larghezza piena */
|
||||
.form-grid .full-width-field {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Stile per checkbox */
|
||||
.form-grid mat-checkbox {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Media Query per schermi più larghi */
|
||||
@media (min-width: 600px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr 1fr; /* Due colonne */
|
||||
}
|
||||
/* Il campo nome occupa entrambe le colonne */
|
||||
.form-grid mat-form-field:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stili generali per i campi */
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Contenuto dialog scrollabile */
|
||||
mat-dialog-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Stile per errore validatore custom */
|
||||
mat-dialog-content > mat-error {
|
||||
margin-bottom: 15px;
|
||||
display: block; /* Assicura che occupi spazio */
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { SchoolYear } from '../../services/school-year.service';
|
||||
|
||||
// Validatore custom per assicurare che end_date sia dopo start_date
|
||||
export const dateRangeValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
|
||||
const start = control.get('start_date');
|
||||
const end = control.get('end_date');
|
||||
|
||||
// Se entrambe le date sono presenti e valide, confrontale
|
||||
if (start?.value && end?.value && start.value instanceof Date && end.value instanceof Date) {
|
||||
return start.value < end.value ? null : { dateRange: true }; // Errore se start >= end
|
||||
}
|
||||
// Se una delle date manca o non è valida, il validatore non si applica (ci pensano i required/datepicker)
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-school-year-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule,
|
||||
MatCheckboxModule
|
||||
],
|
||||
templateUrl: './school-year-dialog.component.html',
|
||||
styleUrl: './school-year-dialog.component.scss'
|
||||
})
|
||||
export class SchoolYearDialogComponent implements OnInit {
|
||||
schoolYearForm: FormGroup;
|
||||
isEditMode: boolean;
|
||||
title: string;
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<SchoolYearDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: { schoolYear?: SchoolYear },
|
||||
private fb: FormBuilder
|
||||
) {
|
||||
this.isEditMode = !!data?.schoolYear;
|
||||
this.title = this.isEditMode ? 'Modifica Anno Scolastico' : 'Aggiungi Nuovo Anno Scolastico';
|
||||
|
||||
this.schoolYearForm = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
start_date: [null, Validators.required], // Data inizio obbligatoria
|
||||
end_date: [null, Validators.required], // Data fine obbligatoria
|
||||
is_active: [false] // Default a false
|
||||
}, { validators: dateRangeValidator }); // Applica il validatore custom al gruppo
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.isEditMode && this.data.schoolYear) {
|
||||
const startDate = this.data.schoolYear.start_date ? new Date(this.data.schoolYear.start_date) : null;
|
||||
const endDate = this.data.schoolYear.end_date ? new Date(this.data.schoolYear.end_date) : null;
|
||||
|
||||
this.schoolYearForm.patchValue({
|
||||
...this.data.schoolYear,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (this.schoolYearForm.valid) {
|
||||
const formData = { ...this.schoolYearForm.value };
|
||||
|
||||
// Formatta le date
|
||||
if (formData.start_date instanceof Date) {
|
||||
formData.start_date = `${formData.start_date.getFullYear()}-${(formData.start_date.getMonth()+1).toString().padStart(2, "0")}-${formData.start_date.getDate()}`
|
||||
}
|
||||
if (formData.end_date instanceof Date) {
|
||||
formData.end_date = `${formData.end_date.getFullYear()}-${(formData.end_date.getMonth()+1).toString().padStart(2, "0")}-${formData.end_date.getDate()}`
|
||||
}
|
||||
|
||||
this.dialogRef.close(formData);
|
||||
} else {
|
||||
console.log('Form Invalid:', this.schoolYearForm.errors);
|
||||
Object.keys(this.schoolYearForm.controls).forEach(key => {
|
||||
const control = this.schoolYearForm.get(key);
|
||||
if (control && control.errors) {
|
||||
console.log(`Control Error - ${key}:`, control.errors);
|
||||
}
|
||||
});
|
||||
this.schoolYearForm.markAllAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<h2>Gestione Anni Scolastici</h2>
|
||||
|
||||
<div class="actions-header">
|
||||
<button mat-raised-button color="primary" (click)="addSchoolYear()">
|
||||
<mat-icon>add_circle_outline</mat-icon> Aggiungi Anno Scolastico
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="spinner-container" *ngIf="isLoading">
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && !error">
|
||||
<table mat-table [dataSource]="(schoolYears$ | async) ?? []" class="mat-elevation-z8 school-year-table">
|
||||
|
||||
<!-- Colonna ID -->
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef> ID </th>
|
||||
<td mat-cell *matCellDef="let year"> {{year.id}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Nome -->
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef> Nome Anno </th>
|
||||
<td mat-cell *matCellDef="let year"> {{year.name}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Data Inizio -->
|
||||
<ng-container matColumnDef="start_date">
|
||||
<th mat-header-cell *matHeaderCellDef> Data Inizio </th>
|
||||
<td mat-cell *matCellDef="let year"> {{year.start_date | date:'dd/MM/yyyy'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Data Fine -->
|
||||
<ng-container matColumnDef="end_date">
|
||||
<th mat-header-cell *matHeaderCellDef> Data Fine </th>
|
||||
<td mat-cell *matCellDef="let year"> {{year.end_date | date:'dd/MM/yyyy'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Attivo -->
|
||||
<ng-container matColumnDef="is_active">
|
||||
<th mat-header-cell *matHeaderCellDef> Attivo </th>
|
||||
<td mat-cell *matCellDef="let year">
|
||||
<mat-slide-toggle
|
||||
[checked]="!!year.is_active"
|
||||
(change)="toggleActive(year, $event)"
|
||||
aria-label="Stato attivo anno scolastico">
|
||||
</mat-slide-toggle>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Azioni -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef> Azioni </th>
|
||||
<td mat-cell *matCellDef="let year">
|
||||
<button mat-icon-button color="primary" aria-label="Modifica anno scolastico" (click)="editSchoolYear(year)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" aria-label="Elimina anno scolastico" (click)="deleteSchoolYear(year)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
|
||||
<!-- Riga da mostrare quando non ci sono dati -->
|
||||
<tr class="mat-row" *matNoDataRow>
|
||||
<td class="mat-cell" [attr.colspan]="displayedColumns.length">
|
||||
Nessun anno scolastico trovato.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
.actions-header {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
padding: 10px;
|
||||
border: 1px solid red;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.school-year-table {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Stile per la riga "Nessun dato" */
|
||||
.mat-row .mat-cell.mat-no-data-row {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
/* Spaziatura tra i pulsanti di azione */
|
||||
td.mat-cell button:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Allineamento verticale per slide-toggle */
|
||||
td.mat-cell mat-slide-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SchoolYearsComponent } from './school-years.component';
|
||||
|
||||
describe('SchoolYearsComponent', () => {
|
||||
let component: SchoolYearsComponent;
|
||||
let fixture: ComponentFixture<SchoolYearsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SchoolYearsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SchoolYearsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common'; // Importa DatePipe
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatSlideToggleModule, MatSlideToggleChange } from '@angular/material/slide-toggle'; // Importa MatSlideToggleChange
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SchoolYearService, SchoolYear, SchoolYearInput } from '../../services/school-year.service';
|
||||
import { ConfirmDialogComponent, ConfirmDialogData } from '../confirm-dialog/confirm-dialog.component';
|
||||
import { SchoolYearDialogComponent } from '../school-year-dialog/school-year-dialog.component'; // Importa il dialog
|
||||
// import { SchoolYearDialogComponent } from '../school-year-dialog/school-year-dialog.component';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-school-years',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
MatSlideToggleModule,
|
||||
DatePipe // Aggiungi DatePipe agli imports
|
||||
],
|
||||
templateUrl: './school-years.component.html',
|
||||
styleUrl: './school-years.component.scss'
|
||||
})
|
||||
export class SchoolYearsComponent implements OnInit {
|
||||
|
||||
schoolYears$: Observable<SchoolYear[]> | undefined;
|
||||
displayedColumns: string[] = ['id', 'name', 'start_date', 'end_date', 'is_active', 'actions'];
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
|
||||
constructor(
|
||||
private schoolYearService: SchoolYearService,
|
||||
private dialog: MatDialog
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSchoolYears();
|
||||
}
|
||||
|
||||
loadSchoolYears(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.schoolYears$ = this.schoolYearService.getSchoolYears();
|
||||
|
||||
this.schoolYears$.subscribe({
|
||||
next: () => this.isLoading = false,
|
||||
error: (err) => {
|
||||
console.error('Error loading school years:', err);
|
||||
this.error = 'Errore durante il caricamento degli anni scolastici.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addSchoolYear(): void {
|
||||
const dialogRef = this.dialog.open(SchoolYearDialogComponent, {
|
||||
width: '500px', // Adatta larghezza se necessario
|
||||
data: {}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.schoolYearService.addSchoolYear(result).subscribe({
|
||||
next: () => this.loadSchoolYears(),
|
||||
error: (err) => {
|
||||
console.error('Error adding school year:', err);
|
||||
this.error = 'Errore durante l\'aggiunta dell\'anno scolastico.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editSchoolYear(schoolYear: SchoolYear): void {
|
||||
// Non serve recuperare dati completi qui, la tabella li ha già
|
||||
const dialogRef = this.dialog.open(SchoolYearDialogComponent, {
|
||||
width: '500px',
|
||||
data: { schoolYear: schoolYear }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.schoolYearService.updateSchoolYear(schoolYear.id, result).subscribe({
|
||||
next: () => this.loadSchoolYears(),
|
||||
error: (err) => {
|
||||
console.error(`Error updating school year ${schoolYear.id}:`, err);
|
||||
this.error = 'Errore durante la modifica dell\'anno scolastico.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteSchoolYear(schoolYear: SchoolYear): void {
|
||||
const dialogData: ConfirmDialogData = {
|
||||
title: 'Conferma Eliminazione Anno Scolastico',
|
||||
message: `Sei sicuro di voler eliminare l'anno scolastico "${schoolYear.name}" (ID: ${schoolYear.id})? Potrebbe avere contratti associati.`, // Aggiungere avviso
|
||||
confirmButtonText: 'Elimina'
|
||||
};
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
width: '400px',
|
||||
data: dialogData
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.isLoading = true;
|
||||
this.schoolYearService.deleteSchoolYear(schoolYear.id).subscribe({
|
||||
next: () => this.loadSchoolYears(),
|
||||
error: (err) => {
|
||||
console.error(`Error deleting school year ${schoolYear.id}:`, err);
|
||||
// Gestire errore specifico (es. 409 Conflict se ci sono contratti)
|
||||
this.error = 'Errore durante l\'eliminazione dell\'anno scolastico. Potrebbe essere in uso.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Gestisce il cambio di stato attivo/inattivo
|
||||
toggleActive(schoolYear: SchoolYear, event: MatSlideToggleChange): void {
|
||||
// Aggiorna lo stato localmente per un feedback immediato (ottimistico)
|
||||
// Nota: questo potrebbe non riflettere lo stato reale se altri anni vengono disattivati dal backend
|
||||
// Ricaricare la lista dopo il successo è più sicuro per la coerenza.
|
||||
// schoolYear.is_active = event.checked; // <- Rimosso aggiornamento ottimistico
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null; // Resetta errore precedente
|
||||
const updatedSchoolYear: SchoolYearInput = { is_active: event.checked };
|
||||
|
||||
this.schoolYearService.updateSchoolYear(schoolYear.id, updatedSchoolYear).subscribe({
|
||||
next: () => {
|
||||
console.log(`Successfully updated active state for ${schoolYear.id}`);
|
||||
this.loadSchoolYears(); // Ricarica l'intera lista per riflettere tutti i cambiamenti (incluso disattivare altri)
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error toggling active state for school year ${schoolYear.id}:`, err);
|
||||
this.error = 'Errore durante l\'aggiornamento dello stato.';
|
||||
this.isLoading = false;
|
||||
// Ricarica comunque per ripristinare lo stato visivo precedente in caso di errore
|
||||
this.loadSchoolYears();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
|
||||
<mat-dialog-content [formGroup]="shiftForm">
|
||||
<!-- Messaggio errore validatore custom -->
|
||||
<mat-error *ngIf="shiftForm.hasError('timeRange') && (shiftForm.get('start_time')?.touched || shiftForm.get('end_time')?.touched)">
|
||||
L'orario di fine deve essere successivo all'orario di inizio.
|
||||
</mat-error>
|
||||
|
||||
<div class="form-grid">
|
||||
|
||||
<mat-form-field appearance="fill" class="full-width-field">
|
||||
<mat-label>Nome Definizione Turno</mat-label>
|
||||
<input matInput formControlName="name" required>
|
||||
<mat-error *ngIf="shiftForm.get('name')?.hasError('required')">
|
||||
Il nome è obbligatorio.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Orario Inizio</mat-label>
|
||||
<input matInput type="time" formControlName="start_time" required placeholder="HH:MM">
|
||||
<mat-error *ngIf="shiftForm.get('start_time')?.hasError('required')">Orario inizio obbligatorio.</mat-error>
|
||||
<mat-error *ngIf="shiftForm.get('start_time')?.hasError('pattern')">Formato ora non valido (HH:MM).</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Orario Fine</mat-label>
|
||||
<input matInput type="time" formControlName="end_time" required placeholder="HH:MM">
|
||||
<mat-error *ngIf="shiftForm.get('end_time')?.hasError('required')">Orario fine obbligatorio.</mat-error>
|
||||
<mat-error *ngIf="shiftForm.get('end_time')?.hasError('pattern')">Formato ora non valido (HH:MM).</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill" class="full-width-field">
|
||||
<mat-label>Note</mat-label>
|
||||
<textarea matInput formControlName="notes" rows="3"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Annulla</button>
|
||||
<button mat-raised-button color="primary" (click)="onSave()" [disabled]="!shiftForm.valid">
|
||||
{{ isEditMode ? 'Salva Modifiche' : 'Aggiungi' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Contenitore per la griglia */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr; /* Default: una colonna */
|
||||
gap: 0 16px; /* Spazio tra colonne */
|
||||
}
|
||||
|
||||
/* Campi a larghezza piena */
|
||||
.form-grid .full-width-field {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Media Query per schermi più larghi */
|
||||
@media (min-width: 600px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr 1fr; /* Due colonne */
|
||||
}
|
||||
/* Fai occupare tutta la larghezza ai campi che devono stare da soli */
|
||||
.form-grid mat-form-field:first-child, /* Nome */
|
||||
.form-grid mat-form-field:has(textarea) /* Textarea */
|
||||
{
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stili generali per i campi */
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Contenuto dialog scrollabile */
|
||||
mat-dialog-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Stile per errore validatore custom */
|
||||
mat-dialog-content > mat-error {
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { ShiftDefinition } from '../../services/shift-definition.service'; // Importa l'interfaccia
|
||||
|
||||
// Validatore custom per assicurare che end_time sia dopo start_time
|
||||
export const timeRangeValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
|
||||
const start = control.get('start_time');
|
||||
const end = control.get('end_time');
|
||||
|
||||
// Confronta solo se entrambi i valori sono presenti e sembrano orari validi (HH:MM o HH:MM:SS)
|
||||
const timeRegex = /^\d{2}:\d{2}(:\d{2})?$/;
|
||||
if (start?.value && end?.value && timeRegex.test(start.value) && timeRegex.test(end.value)) {
|
||||
// Confronto semplice delle stringhe funziona per HH:MM o HH:MM:SS
|
||||
return start.value < end.value ? null : { timeRange: true }; // Errore se start >= end
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-shift-definition-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule
|
||||
],
|
||||
templateUrl: './shift-definition-dialog.component.html',
|
||||
styleUrl: './shift-definition-dialog.component.scss'
|
||||
})
|
||||
export class ShiftDefinitionDialogComponent implements OnInit {
|
||||
shiftForm: FormGroup;
|
||||
isEditMode: boolean;
|
||||
title: string;
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<ShiftDefinitionDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: { shift?: ShiftDefinition },
|
||||
private fb: FormBuilder
|
||||
) {
|
||||
this.isEditMode = !!data?.shift;
|
||||
this.title = this.isEditMode ? 'Modifica Definizione Turno' : 'Aggiungi Nuova Definizione Turno';
|
||||
|
||||
// Validatore per formato HH:MM o HH:MM:SS
|
||||
const timePattern = Validators.pattern(/^([01]\d|2[0-3]):([0-5]\d)(:([0-5]\d))?$/);
|
||||
|
||||
this.shiftForm = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
start_time: ['', [Validators.required, timePattern]],
|
||||
end_time: ['', [Validators.required, timePattern]],
|
||||
notes: ['']
|
||||
}, { validators: timeRangeValidator }); // Applica validatore custom al gruppo
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.isEditMode && this.data.shift) {
|
||||
this.shiftForm.patchValue(this.data.shift);
|
||||
}
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (this.shiftForm.valid) {
|
||||
this.dialogRef.close(this.shiftForm.value);
|
||||
} else {
|
||||
console.log('Shift Form Invalid:', this.shiftForm.errors);
|
||||
Object.keys(this.shiftForm.controls).forEach(key => {
|
||||
const control = this.shiftForm.get(key);
|
||||
if (control && control.errors) {
|
||||
console.log(`Control Error - ${key}:`, control.errors);
|
||||
}
|
||||
});
|
||||
this.shiftForm.markAllAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<h2>Gestione Definizione Turni</h2>
|
||||
|
||||
<div class="actions-header">
|
||||
<button mat-raised-button color="primary" (click)="addShiftDefinition()">
|
||||
<mat-icon>add_alarm</mat-icon> Aggiungi Definizione Turno
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="spinner-container" *ngIf="isLoading">
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && !error">
|
||||
<table mat-table [dataSource]="(shifts$ | async) ?? []" class="mat-elevation-z8 shift-table">
|
||||
|
||||
<!-- Colonna ID -->
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef> ID </th>
|
||||
<td mat-cell *matCellDef="let shift"> {{shift.id}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Nome -->
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef> Nome Turno </th>
|
||||
<td mat-cell *matCellDef="let shift"> {{shift.name}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Ora Inizio -->
|
||||
<ng-container matColumnDef="start_time">
|
||||
<th mat-header-cell *matHeaderCellDef> Ora Inizio </th>
|
||||
<!-- Formattiamo l'ora senza secondi -->
|
||||
<td mat-cell *matCellDef="let shift"> {{shift.start_time | slice:0:5}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Ora Fine -->
|
||||
<ng-container matColumnDef="end_time">
|
||||
<th mat-header-cell *matHeaderCellDef> Ora Fine </th>
|
||||
<td mat-cell *matCellDef="let shift"> {{shift.end_time | slice:0:5}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Azioni -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef> Azioni </th>
|
||||
<td mat-cell *matCellDef="let shift">
|
||||
<button mat-icon-button color="primary" aria-label="Modifica definizione turno" (click)="editShiftDefinition(shift)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" aria-label="Elimina definizione turno" (click)="deleteShiftDefinition(shift)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
|
||||
<!-- Riga da mostrare quando non ci sono dati -->
|
||||
<tr class="mat-row" *matNoDataRow>
|
||||
<td class="mat-cell" [attr.colspan]="displayedColumns.length">
|
||||
Nessuna definizione di turno trovata.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
.actions-header {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
padding: 10px;
|
||||
border: 1px solid red;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.shift-table {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Stile per la riga "Nessun dato" */
|
||||
.mat-row .mat-cell.mat-no-data-row {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
/* Spaziatura tra i pulsanti di azione */
|
||||
td.mat-cell button:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ShiftsComponent } from './shifts.component';
|
||||
|
||||
describe('ShiftsComponent', () => {
|
||||
let component: ShiftsComponent;
|
||||
let fixture: ComponentFixture<ShiftsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ShiftsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ShiftsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ShiftDefinitionService, ShiftDefinition } from '../../services/shift-definition.service';
|
||||
import { ConfirmDialogComponent, ConfirmDialogData } from '../confirm-dialog/confirm-dialog.component';
|
||||
import { ShiftDefinitionDialogComponent } from '../shift-definition-dialog/shift-definition-dialog.component'; // Importa il dialog
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shifts',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule
|
||||
],
|
||||
templateUrl: './shifts.component.html',
|
||||
styleUrl: './shifts.component.scss'
|
||||
})
|
||||
export class ShiftsComponent implements OnInit {
|
||||
|
||||
shifts$: Observable<ShiftDefinition[]> | undefined;
|
||||
displayedColumns: string[] = ['id', 'name', 'start_time', 'end_time', 'actions'];
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
|
||||
constructor(
|
||||
private shiftDefinitionService: ShiftDefinitionService,
|
||||
private dialog: MatDialog
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadShiftDefinitions();
|
||||
}
|
||||
|
||||
loadShiftDefinitions(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.shifts$ = this.shiftDefinitionService.getShiftDefinitions();
|
||||
|
||||
this.shifts$.subscribe({
|
||||
next: () => this.isLoading = false,
|
||||
error: (err) => {
|
||||
console.error('Error loading shift definitions:', err);
|
||||
this.error = 'Errore durante il caricamento delle definizioni turno.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addShiftDefinition(): void {
|
||||
const dialogRef = this.dialog.open(ShiftDefinitionDialogComponent, {
|
||||
width: '450px', // Adatta larghezza
|
||||
data: {}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.shiftDefinitionService.addShiftDefinition(result).subscribe({
|
||||
next: () => this.loadShiftDefinitions(),
|
||||
error: (err) => {
|
||||
console.error('Error adding shift definition:', err);
|
||||
this.error = err.error?.error || 'Errore durante l\'aggiunta della definizione turno.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editShiftDefinition(shift: ShiftDefinition): void {
|
||||
const dialogRef = this.dialog.open(ShiftDefinitionDialogComponent, {
|
||||
width: '450px',
|
||||
data: { shift: shift } // Passa la definizione esistente
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.shiftDefinitionService.updateShiftDefinition(shift.id, result).subscribe({
|
||||
next: () => this.loadShiftDefinitions(),
|
||||
error: (err) => {
|
||||
console.error(`Error updating shift definition ${shift.id}:`, err);
|
||||
this.error = err.error?.error || 'Errore durante la modifica della definizione turno.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteShiftDefinition(shift: ShiftDefinition): void {
|
||||
const dialogData: ConfirmDialogData = {
|
||||
title: 'Conferma Eliminazione Definizione Turno',
|
||||
message: `Sei sicuro di voler eliminare la definizione turno "${shift.name}" (ID: ${shift.id})?`,
|
||||
confirmButtonText: 'Elimina'
|
||||
};
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
width: '400px',
|
||||
data: dialogData
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.isLoading = true;
|
||||
this.shiftDefinitionService.deleteShiftDefinition(shift.id).subscribe({
|
||||
next: () => this.loadShiftDefinitions(),
|
||||
error: (err) => {
|
||||
console.error(`Error deleting shift definition ${shift.id}:`, err);
|
||||
this.error = 'Errore durante l\'eliminazione della definizione turno.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<h2 mat-dialog-title>{{ isEditMode ? 'Modifica Struttura' : 'Aggiungi Nuova Struttura' }}</h2>
|
||||
|
||||
<mat-dialog-content [formGroup]="structureForm">
|
||||
<div class="form-grid"> <!-- Contenitore per la griglia -->
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Nome Struttura</mat-label>
|
||||
<input matInput formControlName="name" required>
|
||||
<mat-error *ngIf="structureForm.get('name')?.hasError('required')">
|
||||
Il nome è obbligatorio.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Indirizzo</mat-label>
|
||||
<input matInput formControlName="address">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Città</mat-label>
|
||||
<input matInput formControlName="city">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Provincia</mat-label>
|
||||
<input matInput formControlName="province">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>CAP</mat-label>
|
||||
<input matInput formControlName="zip_code">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Telefono</mat-label>
|
||||
<input matInput formControlName="phone">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput formControlName="email" type="email">
|
||||
<mat-error *ngIf="structureForm.get('email')?.hasError('email')">
|
||||
Inserisci un indirizzo email valido.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill" class="full-width-field"> <!-- Campo note a larghezza piena -->
|
||||
<mat-label>Note</mat-label>
|
||||
<textarea matInput formControlName="notes"></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Annulla</button>
|
||||
<button mat-raised-button color="primary" (click)="onSave()" [disabled]="!structureForm.valid">
|
||||
{{ isEditMode ? 'Salva Modifiche' : 'Aggiungi' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,31 @@
|
||||
/* Stili di base per i campi (già presenti) */
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Contenitore per la griglia */
|
||||
.form-grid {
|
||||
display: grid; /* Usa CSS Grid */
|
||||
grid-template-columns: 1fr; /* Default: una colonna per mobile */
|
||||
gap: 0 16px; /* Spazio tra colonne (0 per verticale, 16px per orizzontale) */
|
||||
}
|
||||
|
||||
/* Media Query per schermi più larghi (es. > 600px) */
|
||||
@media (min-width: 600px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr 1fr; /* Due colonne di uguale larghezza */
|
||||
}
|
||||
|
||||
/* Fai in modo che il campo 'notes' occupi entrambe le colonne */
|
||||
.form-grid .full-width-field {
|
||||
grid-column: 1 / -1; /* Occupa dalla prima all'ultima colonna */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Opzionale: Rende il contenuto del dialog scrollabile se necessario */
|
||||
mat-dialog-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { Structure } from '../../services/structure.service'; // Importa l'interfaccia
|
||||
|
||||
@Component({
|
||||
selector: 'app-structure-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule, // Importa ReactiveFormsModule
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule
|
||||
],
|
||||
templateUrl: './structure-dialog.component.html',
|
||||
styleUrl: './structure-dialog.component.scss'
|
||||
})
|
||||
export class StructureDialogComponent {
|
||||
structureForm: FormGroup;
|
||||
isEditMode: boolean;
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<StructureDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: { structure?: Structure }, // Dati passati al dialog (opzionale per modifica)
|
||||
private fb: FormBuilder
|
||||
) {
|
||||
this.isEditMode = !!data?.structure; // Verifica se siamo in modalità modifica
|
||||
|
||||
// Inizializza il form con FormBuilder
|
||||
this.structureForm = this.fb.group({
|
||||
name: [data?.structure?.name || '', Validators.required], // Campo 'name' obbligatorio
|
||||
address: [data?.structure?.address || ''],
|
||||
city: [data?.structure?.city || ''],
|
||||
province: [data?.structure?.province || ''],
|
||||
zip_code: [data?.structure?.zip_code || ''],
|
||||
phone: [data?.structure?.phone || ''],
|
||||
email: [data?.structure?.email || '', Validators.email], // Validatore email
|
||||
notes: [data?.structure?.notes || '']
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(); // Chiude il dialog senza restituire dati
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (this.structureForm.valid) {
|
||||
// Restituisce i dati del form quando si salva
|
||||
this.dialogRef.close(this.structureForm.value);
|
||||
} else {
|
||||
// Opzionale: marca i campi come toccati per mostrare gli errori
|
||||
this.structureForm.markAllAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<h2>Gestione Strutture</h2>
|
||||
|
||||
<div class="actions-header">
|
||||
<button mat-raised-button color="primary" (click)="addStructure()">
|
||||
<mat-icon>add</mat-icon> Aggiungi Struttura
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="spinner-container" *ngIf="isLoading">
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && !error">
|
||||
<table mat-table [dataSource]="(structures$ | async) ?? []" class="mat-elevation-z8 structure-table">
|
||||
|
||||
<!-- Colonna ID -->
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef> ID </th>
|
||||
<td mat-cell *matCellDef="let structure"> {{structure.id}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Nome -->
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef> Nome </th>
|
||||
<td mat-cell *matCellDef="let structure"> {{structure.name}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Città -->
|
||||
<ng-container matColumnDef="city">
|
||||
<th mat-header-cell *matHeaderCellDef> Città </th>
|
||||
<td mat-cell *matCellDef="let structure"> {{structure.city || '-'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Email -->
|
||||
<ng-container matColumnDef="email">
|
||||
<th mat-header-cell *matHeaderCellDef> Email </th>
|
||||
<td mat-cell *matCellDef="let structure"> {{structure.email || '-'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Azioni -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef> Azioni </th>
|
||||
<td mat-cell *matCellDef="let structure">
|
||||
<button mat-icon-button color="primary" aria-label="Modifica struttura" (click)="editStructure(structure)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" aria-label="Elimina struttura" (click)="deleteStructure(structure)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
|
||||
<!-- Riga da mostrare quando non ci sono dati -->
|
||||
<tr class="mat-row" *matNoDataRow>
|
||||
<td class="mat-cell" [attr.colspan]="displayedColumns.length">
|
||||
Nessuna struttura trovata.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
.actions-header {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end; /* Allinea il pulsante a destra */
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
padding: 10px;
|
||||
border: 1px solid red;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.structure-table {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Stile per la riga "Nessun dato" */
|
||||
.mat-row .mat-cell.mat-no-data-row {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: rgba(0, 0, 0, 0.54); /* Colore grigio standard di Material */
|
||||
}
|
||||
|
||||
/* Spaziatura tra i pulsanti di azione */
|
||||
td.mat-cell button:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { StructuresComponent } from './structures.component';
|
||||
|
||||
describe('StructuresComponent', () => {
|
||||
let component: StructuresComponent;
|
||||
let fixture: ComponentFixture<StructuresComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StructuresComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StructuresComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog'; // Importa MatDialog
|
||||
import { StructureService, Structure } from '../../services/structure.service';
|
||||
import { StructureDialogComponent } from '../structure-dialog/structure-dialog.component';
|
||||
import { ConfirmDialogComponent, ConfirmDialogData } from '../confirm-dialog/confirm-dialog.component'; // Importa il dialog di conferma
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-structures',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule // Aggiungi MatDialogModule agli imports
|
||||
],
|
||||
templateUrl: './structures.component.html',
|
||||
styleUrl: './structures.component.scss'
|
||||
})
|
||||
export class StructuresComponent implements OnInit {
|
||||
|
||||
structures$: Observable<Structure[]> | undefined; // Observable per i dati della tabella
|
||||
displayedColumns: string[] = ['id', 'name', 'city', 'email', 'actions']; // Colonne da visualizzare
|
||||
isLoading = false; // Flag per indicatore di caricamento
|
||||
error: string | null = null; // Per messaggi di errore
|
||||
|
||||
constructor(
|
||||
private structureService: StructureService,
|
||||
private dialog: MatDialog // Inietta MatDialog
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStructures();
|
||||
}
|
||||
|
||||
loadStructures(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.structures$ = this.structureService.getStructures();
|
||||
|
||||
// Gestione semplice del caricamento e degli errori (potrebbe essere migliorata con catchError, finalize)
|
||||
this.structures$.subscribe({
|
||||
next: () => this.isLoading = false,
|
||||
error: (err) => {
|
||||
console.error('Error loading structures:', err);
|
||||
this.error = 'Errore durante il caricamento delle strutture.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addStructure(): void {
|
||||
const dialogRef = this.dialog.open(StructureDialogComponent, {
|
||||
width: '400px', // Imposta la larghezza del dialog
|
||||
data: {} // Passa un oggetto vuoto perché è un'aggiunta
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
// 'result' contiene i dati del form se l'utente ha salvato
|
||||
if (result) {
|
||||
this.isLoading = true; // Mostra spinner durante il salvataggio
|
||||
this.structureService.addStructure(result).subscribe({
|
||||
next: () => {
|
||||
this.loadStructures(); // Ricarica la lista dopo l'aggiunta
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error adding structure:', err);
|
||||
this.error = 'Errore durante l\'aggiunta della struttura.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editStructure(structure: Structure): void {
|
||||
const dialogRef = this.dialog.open(StructureDialogComponent, {
|
||||
width: '400px',
|
||||
data: { structure: structure } // Passa la struttura esistente al dialog
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
// 'result' contiene i dati aggiornati del form se l'utente ha salvato
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.structureService.updateStructure(structure.id, result).subscribe({
|
||||
next: () => {
|
||||
this.loadStructures(); // Ricarica la lista dopo la modifica
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error updating structure ${structure.id}:`, err);
|
||||
this.error = 'Errore durante la modifica della struttura.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteStructure(structure: Structure): void {
|
||||
const dialogData: ConfirmDialogData = {
|
||||
title: 'Conferma Eliminazione',
|
||||
message: `Sei sicuro di voler eliminare la struttura "${structure.name}" (ID: ${structure.id})? L'azione non è reversibile.`,
|
||||
confirmButtonText: 'Elimina',
|
||||
cancelButtonText: 'Annulla'
|
||||
};
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
width: '400px',
|
||||
data: dialogData
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.isLoading = true; // Mostra spinner durante l'eliminazione
|
||||
this.structureService.deleteStructure(structure.id).subscribe({
|
||||
next: () => {
|
||||
this.loadStructures(); // Ricarica la lista dopo l'eliminazione
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error deleting structure ${structure.id}:`, err);
|
||||
this.error = 'Errore durante l\'eliminazione della struttura.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
|
||||
<mat-dialog-content [formGroup]="contractForm">
|
||||
<!-- Messaggio errore validatore date -->
|
||||
<mat-error *ngIf="contractForm.hasError('dateRange') && (contractForm.get('start_date')?.touched || contractForm.get('end_date')?.touched)">
|
||||
La data di fine deve essere successiva alla data di inizio.
|
||||
</mat-error>
|
||||
|
||||
<!-- Indicatore caricamento per le select -->
|
||||
<div *ngIf="isLoadingSelects" style="text-align: center; padding: 20px;">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<p>Caricamento dati...</p>
|
||||
</div>
|
||||
|
||||
<div class="form-grid" *ngIf="!isLoadingSelects">
|
||||
|
||||
<!-- Select Insegnante -->
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Insegnante</mat-label>
|
||||
<mat-select formControlName="teacher_id" required>
|
||||
<!-- Itera sull'array teachers -->
|
||||
<mat-option *ngFor="let teacher of teachers" [value]="teacher.id">
|
||||
{{ teacher.last_name }} {{ teacher.first_name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="contractForm.get('teacher_id')?.hasError('required')">
|
||||
Selezionare un insegnante è obbligatorio.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Select Anno Scolastico -->
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Anno Scolastico</mat-label>
|
||||
<mat-select formControlName="school_year_id" required>
|
||||
<!-- Itera sull'array schoolYears -->
|
||||
<mat-option *ngFor="let year of schoolYears" [value]="year.id">
|
||||
{{ year.name }} ({{ year.start_date | date:'dd/MM/yy' }} - {{ year.end_date | date:'dd/MM/yy' }})
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="contractForm.get('school_year_id')?.hasError('required')">
|
||||
Selezionare un anno scolastico è obbligatorio.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Select Struttura (Opzionale) -->
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Struttura (Opzionale)</mat-label>
|
||||
<mat-select formControlName="structure_id">
|
||||
<mat-option [value]="null">-- Nessuna --</mat-option> <!-- Opzione per non selezionare -->
|
||||
<mat-option *ngFor="let structure of (structures$ | async)" [value]="structure.id">
|
||||
{{ structure.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Tipo Contratto -->
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Tipo Contratto</mat-label>
|
||||
<input matInput formControlName="contract_type">
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Data Inizio -->
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Data Inizio Contratto</mat-label>
|
||||
<input matInput [matDatepicker]="contractStartPicker" formControlName="start_date" required readonly>
|
||||
<mat-hint>GG/MM/AAAA</mat-hint>
|
||||
<mat-datepicker-toggle matSuffix [for]="contractStartPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #contractStartPicker></mat-datepicker>
|
||||
<mat-error *ngIf="contractForm.get('start_date')?.hasError('required')">
|
||||
La data di inizio è obbligatoria.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Data Fine (Opzionale) -->
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Data Fine Contratto (Opzionale)</mat-label>
|
||||
<input matInput [matDatepicker]="contractEndPicker" formControlName="end_date" readonly>
|
||||
<mat-hint>GG/MM/AAAA</mat-hint>
|
||||
<mat-datepicker-toggle matSuffix [for]="contractEndPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #contractEndPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Ore Settimanali -->
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Ore Settimanali</mat-label>
|
||||
<input matInput type="number" formControlName="weekly_hours" min="0" step="0.01">
|
||||
<mat-error *ngIf="contractForm.get('weekly_hours')?.hasError('pattern') || contractForm.get('weekly_hours')?.hasError('min')">
|
||||
Inserire un numero positivo (max 2 decimali).
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Stipendio -->
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Stipendio</mat-label>
|
||||
<input matInput type="number" formControlName="salary" min="0" step="0.01">
|
||||
<mat-error *ngIf="contractForm.get('salary')?.hasError('pattern') || contractForm.get('salary')?.hasError('min')">
|
||||
Inserire un numero positivo (max 2 decimali).
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Note -->
|
||||
<mat-form-field appearance="fill" class="full-width-field">
|
||||
<mat-label>Note</mat-label>
|
||||
<textarea matInput formControlName="notes" rows="3"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Annulla</button>
|
||||
<button mat-raised-button color="primary" (click)="onSave()" [disabled]="!contractForm.valid">
|
||||
{{ isEditMode ? 'Salva Modifiche' : 'Aggiungi' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Contenitore per la griglia */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr; /* Default: una colonna */
|
||||
gap: 0 16px; /* Spazio tra colonne */
|
||||
}
|
||||
|
||||
/* Campi a larghezza piena */
|
||||
.form-grid .full-width-field {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Media Query per schermi più larghi */
|
||||
@media (min-width: 600px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr 1fr; /* Due colonne */
|
||||
}
|
||||
/* Fai occupare tutta la larghezza ai campi che devono stare da soli */
|
||||
.form-grid mat-form-field:has(textarea), /* Textarea */
|
||||
.form-grid > div.full-width-field /* Div con checkbox */
|
||||
{
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stili generali per i campi */
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Contenuto dialog scrollabile */
|
||||
mat-dialog-content {
|
||||
max-height: 75vh; /* Un po' più alto per form più lunghi */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Stile per errore validatore custom */
|
||||
mat-dialog-content > mat-error {
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; // Rimosso ValidatorFn, AbstractControl, ValidationErrors
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatSelectModule } from '@angular/material/select'; // Per le select
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; // Per caricamento select
|
||||
import { Observable, forkJoin } from 'rxjs'; // Per caricare dati multipli
|
||||
import { TeacherContract } from '../../services/teacher-contract.service';
|
||||
import { Teacher, TeacherService } from '../../services/teacher.service';
|
||||
import { SchoolYear, SchoolYearService } from '../../services/school-year.service';
|
||||
import { Structure, StructureService } from '../../services/structure.service';
|
||||
|
||||
// Validatore custom rimosso, la validazione del range verrà fatta dal backend
|
||||
|
||||
@Component({
|
||||
selector: 'app-teacher-contract-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule,
|
||||
MatSelectModule, // Aggiungi MatSelectModule
|
||||
MatProgressSpinnerModule
|
||||
],
|
||||
templateUrl: './teacher-contract-dialog.component.html',
|
||||
styleUrl: './teacher-contract-dialog.component.scss'
|
||||
})
|
||||
export class TeacherContractDialogComponent implements OnInit {
|
||||
contractForm: FormGroup;
|
||||
isEditMode: boolean;
|
||||
title: string;
|
||||
|
||||
// Liste per le select
|
||||
teachers: Teacher[] = []; // Array per popolare la select
|
||||
schoolYears: SchoolYear[] = []; // Array per popolare la select e cercare la data
|
||||
structures$: Observable<Structure[]> | undefined;
|
||||
isLoadingSelects = false;
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<TeacherContractDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: { contract?: TeacherContract },
|
||||
private fb: FormBuilder,
|
||||
// Iniettiamo i servizi necessari per popolare le select
|
||||
private teacherService: TeacherService,
|
||||
private schoolYearService: SchoolYearService,
|
||||
private structureService: StructureService
|
||||
) {
|
||||
this.isEditMode = !!data?.contract;
|
||||
this.title = this.isEditMode ? 'Modifica Contratto Insegnante' : 'Aggiungi Nuovo Contratto';
|
||||
|
||||
this.contractForm = this.fb.group({
|
||||
teacher_id: [null, Validators.required],
|
||||
school_year_id: [null, Validators.required],
|
||||
structure_id: [null], // Opzionale
|
||||
contract_type: [''],
|
||||
start_date: [null, Validators.required],
|
||||
end_date: [null], // Opzionale
|
||||
weekly_hours: [null, [Validators.min(0), Validators.pattern(/^\d+(\.\d{1,2})?$/)]], // Numero positivo con max 2 decimali
|
||||
salary: [null, [Validators.min(0), Validators.pattern(/^\d+(\.\d{1,2})?$/)]],
|
||||
notes: ['']
|
||||
}); // Rimosso validatore custom dal gruppo
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSelectData(); // Carica dati per le select
|
||||
|
||||
if (this.isEditMode && this.data.contract) {
|
||||
const startDate = this.data.contract.start_date ? new Date(this.data.contract.start_date) : null;
|
||||
const endDate = this.data.contract.end_date ? new Date(this.data.contract.end_date) : null;
|
||||
|
||||
this.contractForm.patchValue({
|
||||
...this.data.contract,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
// teacher_id, school_year_id, structure_id sono già nel data.contract
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadSelectData(): void {
|
||||
this.isLoadingSelects = true;
|
||||
// Usiamo forkJoin per caricare tutti i dati in parallelo
|
||||
forkJoin({
|
||||
teachers: this.teacherService.getTeachers(),
|
||||
schoolYears: this.schoolYearService.getSchoolYears(),
|
||||
structures: this.structureService.getStructures()
|
||||
}).subscribe({
|
||||
next: (data) => {
|
||||
this.teachers = data.teachers;
|
||||
this.schoolYears = data.schoolYears;
|
||||
// Converti structures$ in un array per coerenza, se necessario altrove, o mantieni Observable
|
||||
this.structures$ = new Observable(sub => sub.next(data.structures)); // Manteniamo Observable per structures per ora
|
||||
this.isLoadingSelects = false;
|
||||
this.setupSchoolYearSubscription(); // Attiva la sottoscrizione DOPO aver caricato i dati
|
||||
},
|
||||
error: (err) => {
|
||||
console.error("Error loading select data for contract dialog:", err);
|
||||
this.isLoadingSelects = false;
|
||||
this.dialogRef.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sottoscrive ai cambiamenti dell'anno scolastico selezionato
|
||||
setupSchoolYearSubscription(): void {
|
||||
const schoolYearControl = this.contractForm.get('school_year_id');
|
||||
if (schoolYearControl) {
|
||||
schoolYearControl.valueChanges.subscribe(selectedYearId => {
|
||||
if (selectedYearId) {
|
||||
const selectedYear = this.schoolYears.find(year => year.id === selectedYearId);
|
||||
if (selectedYear) {
|
||||
// Estrai l'anno dalla data di inizio dell'anno scolastico
|
||||
const startYear = new Date(selectedYear.start_date).getFullYear();
|
||||
// Trova il primo giorno di settembre
|
||||
let firstWorkingDay = new Date(startYear, 8, 1); // Mese 8 = Settembre (0-based)
|
||||
// Controlla il giorno della settimana (0=Domenica, 6=Sabato)
|
||||
while (firstWorkingDay.getDay() === 0 || firstWorkingDay.getDay() === 6) {
|
||||
firstWorkingDay.setDate(firstWorkingDay.getDate() + 1); // Passa al giorno successivo
|
||||
}
|
||||
// Aggiorna il campo start_date del form
|
||||
this.contractForm.patchValue({ start_date: firstWorkingDay });
|
||||
}
|
||||
} else {
|
||||
// Se l'anno viene deselezionato, potresti voler resettare la data inizio
|
||||
// this.contractForm.patchValue({ start_date: null });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (this.contractForm.valid) {
|
||||
const formData = { ...this.contractForm.value };
|
||||
|
||||
// Formatta le date
|
||||
if (formData.start_date instanceof Date) {
|
||||
formData.start_date = `${formData.start_date.getFullYear()}-${(formData.start_date.getMonth()+1).toString().padStart(2, "0")}-${formData.start_date.getDate()}`
|
||||
}
|
||||
if (formData.end_date instanceof Date) {
|
||||
formData.end_date = `${formData.end_date.getFullYear()}-${(formData.end_date.getMonth()+1).toString().padStart(2, "0")}-${formData.end_date.getDate()}`
|
||||
} else if (formData.end_date === null || formData.end_date === '') {
|
||||
formData.end_date = null; // Assicura che sia null se vuoto
|
||||
}
|
||||
|
||||
// Converte numeri stringa (se presenti) in numeri float
|
||||
if (formData.weekly_hours !== null && formData.weekly_hours !== '') {
|
||||
formData.weekly_hours = parseFloat(formData.weekly_hours);
|
||||
} else {
|
||||
formData.weekly_hours = null;
|
||||
}
|
||||
if (formData.salary !== null && formData.salary !== '') {
|
||||
formData.salary = parseFloat(formData.salary);
|
||||
} else {
|
||||
formData.salary = null;
|
||||
}
|
||||
// Assicura che i campi opzionali siano effettivamente null se vuoti/non selezionati
|
||||
// (il form potrebbe avere '' o undefined, ma vogliamo inviare null)
|
||||
const fieldsToNullCheck = ['structure_id', 'end_date', 'weekly_hours', 'salary', 'notes', 'contract_type'];
|
||||
fieldsToNullCheck.forEach(field => {
|
||||
if (formData[field] === '' || formData[field] === undefined) {
|
||||
formData[field] = null;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.dialogRef.close(formData);
|
||||
} else {
|
||||
console.log('Contract Form Invalid:', this.contractForm.errors);
|
||||
Object.keys(this.contractForm.controls).forEach(key => {
|
||||
const control = this.contractForm.get(key);
|
||||
if (control && control.errors) {
|
||||
console.log(`Control Error - ${key}:`, control.errors);
|
||||
}
|
||||
});
|
||||
this.contractForm.markAllAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
|
||||
<mat-dialog-content [formGroup]="teacherForm">
|
||||
<div class="form-grid">
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Nome</mat-label>
|
||||
<input matInput formControlName="first_name" required>
|
||||
<mat-error *ngIf="teacherForm.get('first_name')?.hasError('required')">
|
||||
Il nome è obbligatorio.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Cognome</mat-label>
|
||||
<input matInput formControlName="last_name" required>
|
||||
<mat-error *ngIf="teacherForm.get('last_name')?.hasError('required')">
|
||||
Il cognome è obbligatorio.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput formControlName="email" type="email">
|
||||
<mat-error *ngIf="teacherForm.get('email')?.hasError('email')">
|
||||
Inserisci un indirizzo email valido.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Telefono</mat-label>
|
||||
<input matInput formControlName="phone">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Data di Nascita</mat-label>
|
||||
<input matInput [matDatepicker]="dobPicker" formControlName="date_of_birth" readonly> <!-- Ripristinato readonly -->
|
||||
<mat-hint>GG/MM/AAAA</mat-hint>
|
||||
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Data Assunzione</mat-label>
|
||||
<input matInput [matDatepicker]="hirePicker" formControlName="hire_date" readonly> <!-- Ripristinato readonly -->
|
||||
<mat-hint>GG/MM/AAAA</mat-hint>
|
||||
<mat-datepicker-toggle matSuffix [for]="hirePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #hirePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="full-width-field"> <!-- Contenitore per checkbox/textarea -->
|
||||
<mat-checkbox formControlName="is_active" labelPosition="after">
|
||||
Insegnante Attivo
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
|
||||
<mat-form-field appearance="fill" class="full-width-field">
|
||||
<mat-label>Qualifiche/Note</mat-label>
|
||||
<textarea matInput formControlName="qualifications" rows="3"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Annulla</button>
|
||||
<button mat-raised-button color="primary" (click)="onSave()" [disabled]="!teacherForm.valid"> <!-- Ripristinato controllo validità -->
|
||||
{{ isEditMode ? 'Salva Modifiche' : 'Aggiungi' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,37 @@
|
||||
/* Contenitore per la griglia */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr; /* Default: una colonna */
|
||||
gap: 0 16px; /* Spazio tra colonne */
|
||||
}
|
||||
|
||||
/* Campi a larghezza piena (es. checkbox, textarea) */
|
||||
.form-grid .full-width-field {
|
||||
grid-column: 1 / -1; /* Occupa tutte le colonne disponibili */
|
||||
margin-top: 10px; /* Aggiunge spazio sopra */
|
||||
}
|
||||
|
||||
/* Stile per checkbox */
|
||||
.form-grid mat-checkbox {
|
||||
margin-bottom: 10px; /* Spazio sotto la checkbox */
|
||||
}
|
||||
|
||||
|
||||
/* Media Query per schermi più larghi */
|
||||
@media (min-width: 600px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr 1fr; /* Due colonne */
|
||||
}
|
||||
}
|
||||
|
||||
/* Stili generali per i campi (opzionale, se non già globali) */
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Contenuto dialog scrollabile */
|
||||
mat-dialog-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Component, Inject, OnInit } from '@angular/core'; // Rimuovi ChangeDetectorRef, OnDestroy
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker'; // Per date
|
||||
import { MatNativeDateModule } from '@angular/material/core'; // Necessario per MatDatepicker
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { Teacher } from '../../services/teacher.service'; // Importa l'interfaccia
|
||||
// import { Subscription } from 'rxjs'; // Rimuovi Subscription
|
||||
|
||||
@Component({
|
||||
selector: 'app-teacher-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule, // Importa MatNativeDateModule
|
||||
MatCheckboxModule
|
||||
],
|
||||
templateUrl: './teacher-dialog.component.html',
|
||||
styleUrl: './teacher-dialog.component.scss',
|
||||
})
|
||||
export class TeacherDialogComponent implements OnInit { // Rimuovi OnDestroy
|
||||
teacherForm: FormGroup;
|
||||
isEditMode: boolean;
|
||||
title: string;
|
||||
// private subscriptions = new Subscription(); // Rimuovi subscriptions
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<TeacherDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: { teacher?: Teacher },
|
||||
private fb: FormBuilder,
|
||||
// private cdr: ChangeDetectorRef // Rimuovi cdr
|
||||
) {
|
||||
this.isEditMode = !!data?.teacher;
|
||||
this.title = this.isEditMode ? 'Modifica Insegnante' : 'Aggiungi Nuovo Insegnante';
|
||||
|
||||
// Inizializza il form
|
||||
this.teacherForm = this.fb.group({
|
||||
first_name: ['', Validators.required],
|
||||
last_name: ['', Validators.required],
|
||||
email: ['', [Validators.email]], // Ripristinato validatore email
|
||||
phone: [''],
|
||||
date_of_birth: [null], // Inizializza a null per il datepicker
|
||||
hire_date: [null],
|
||||
qualifications: [''],
|
||||
is_active: [true] // Default a true per nuovi insegnanti
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Se siamo in modalità modifica, popola il form con i dati esistenti
|
||||
if (this.isEditMode && this.data.teacher) {
|
||||
// Converte le date stringa in oggetti Date se necessario per il datepicker
|
||||
const dob = this.data.teacher.date_of_birth ? new Date(this.data.teacher.date_of_birth) : null;
|
||||
const hireDate = this.data.teacher.hire_date ? new Date(this.data.teacher.hire_date) : null;
|
||||
|
||||
this.teacherForm.patchValue({
|
||||
...this.data.teacher,
|
||||
date_of_birth: dob,
|
||||
hire_date: hireDate
|
||||
});
|
||||
|
||||
} // Fine if (this.isEditMode...)
|
||||
}
|
||||
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (this.teacherForm.valid) {
|
||||
const formData = { ...this.teacherForm.value };
|
||||
|
||||
// Formatta le date prima di inviarle (es. YYYY-MM-DD) se necessario
|
||||
// Il backend si aspetta un formato DATE SQL standard
|
||||
if (formData.date_of_birth instanceof Date) {
|
||||
formData.date_of_birth = `${formData.date_of_birth.getFullYear()}-${(formData.date_of_birth.getMonth()+1).toString().padStart(2, "0")}-${formData.date_of_birth.getDate()}`
|
||||
}
|
||||
if (formData.hire_date instanceof Date) {
|
||||
formData.hire_date = `${formData.hire_date.getFullYear()}-${(formData.hire_date.getMonth()+1).toString().padStart(2, "0")}-${formData.hire_date.getDate()}`
|
||||
}
|
||||
|
||||
this.dialogRef.close(formData);
|
||||
} else {
|
||||
console.log('--- Form Debug ---');
|
||||
console.log('Form Status:', this.teacherForm.status);
|
||||
console.log('Form Valid:', this.teacherForm.valid);
|
||||
console.log('Form Errors:', this.teacherForm.errors);
|
||||
console.log('--- Control Details ---');
|
||||
Object.keys(this.teacherForm.controls).forEach(key => {
|
||||
const control = this.teacherForm.get(key);
|
||||
if (control) {
|
||||
console.log(`Control: ${key}, Status: ${control.status}, Valid: ${control.valid}, Value:`, control.value, 'Errors:', control.errors);
|
||||
}
|
||||
});
|
||||
this.teacherForm.markAllAsTouched();
|
||||
}
|
||||
}
|
||||
|
||||
// ngOnDestroy(): void { // Rimuovi ngOnDestroy
|
||||
// this.subscriptions.unsubscribe();
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<h2>Gestione Insegnanti</h2>
|
||||
|
||||
<div class="actions-header">
|
||||
<button mat-raised-button color="primary" (click)="addTeacher()">
|
||||
<mat-icon>person_add</mat-icon> Aggiungi Insegnante
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="spinner-container" *ngIf="isLoading">
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && !error">
|
||||
<table mat-table [dataSource]="(teachers$ | async) ?? []" class="mat-elevation-z8 teacher-table">
|
||||
|
||||
<!-- Colonna ID -->
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef> ID </th>
|
||||
<td mat-cell *matCellDef="let teacher"> {{teacher.id}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Cognome -->
|
||||
<ng-container matColumnDef="last_name">
|
||||
<th mat-header-cell *matHeaderCellDef> Cognome </th>
|
||||
<td mat-cell *matCellDef="let teacher"> {{teacher.last_name}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Nome -->
|
||||
<ng-container matColumnDef="first_name">
|
||||
<th mat-header-cell *matHeaderCellDef> Nome </th>
|
||||
<td mat-cell *matCellDef="let teacher"> {{teacher.first_name}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Email -->
|
||||
<ng-container matColumnDef="email">
|
||||
<th mat-header-cell *matHeaderCellDef> Email </th>
|
||||
<td mat-cell *matCellDef="let teacher"> {{teacher.email || '-'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Telefono -->
|
||||
<ng-container matColumnDef="phone">
|
||||
<th mat-header-cell *matHeaderCellDef> Telefono </th>
|
||||
<td mat-cell *matCellDef="let teacher"> {{teacher.phone || '-'}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Attivo -->
|
||||
<ng-container matColumnDef="is_active">
|
||||
<th mat-header-cell *matHeaderCellDef> Attivo </th>
|
||||
<td mat-cell *matCellDef="let teacher">
|
||||
<mat-slide-toggle
|
||||
[checked]="teacher.is_active"
|
||||
(change)="toggleActive(teacher, $event)"
|
||||
aria-label="Stato attivo insegnante">
|
||||
</mat-slide-toggle>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Colonna Azioni -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef> Azioni </th>
|
||||
<td mat-cell *matCellDef="let teacher">
|
||||
<button mat-icon-button color="primary" aria-label="Modifica insegnante" (click)="editTeacher(teacher)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" aria-label="Elimina insegnante" (click)="deleteTeacher(teacher)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
|
||||
<!-- Riga da mostrare quando non ci sono dati -->
|
||||
<tr class="mat-row" *matNoDataRow>
|
||||
<td class="mat-cell" [attr.colspan]="displayedColumns.length">
|
||||
Nessun insegnante trovato.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
.actions-header {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
padding: 10px;
|
||||
border: 1px solid red;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.teacher-table {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Stile per la riga "Nessun dato" */
|
||||
.mat-row .mat-cell.mat-no-data-row {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
/* Spaziatura tra i pulsanti di azione */
|
||||
td.mat-cell button:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Allineamento verticale per slide-toggle */
|
||||
td.mat-cell mat-slide-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%; /* Assicura che occupi l'altezza della cella */
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { TeachersComponent } from './teachers.component';
|
||||
|
||||
describe('TeachersComponent', () => {
|
||||
let component: TeachersComponent;
|
||||
let fixture: ComponentFixture<TeachersComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TeachersComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TeachersComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatSlideToggleModule, MatSlideToggleChange } from '@angular/material/slide-toggle'; // Importa anche MatSlideToggleChange
|
||||
import { FormsModule } from '@angular/forms'; // Necessario per ngModel in mat-slide-toggle
|
||||
import { TeacherService, Teacher, TeacherInput } from '../../services/teacher.service'; // Importa servizio e interfacce
|
||||
import { ConfirmDialogComponent, ConfirmDialogData } from '../confirm-dialog/confirm-dialog.component';
|
||||
import { TeacherDialogComponent } from '../teacher-dialog/teacher-dialog.component'; // Importa il dialog insegnanti
|
||||
// import { TeacherDialogComponent } from '../teacher-dialog/teacher-dialog.component';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-teachers',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule, // Aggiungi FormsModule
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
MatSlideToggleModule // Aggiungi MatSlideToggleModule
|
||||
],
|
||||
templateUrl: './teachers.component.html',
|
||||
styleUrl: './teachers.component.scss'
|
||||
})
|
||||
export class TeachersComponent implements OnInit {
|
||||
|
||||
teachers$: Observable<Teacher[]> | undefined;
|
||||
// Colonne per la tabella insegnanti (adattate)
|
||||
displayedColumns: string[] = ['id', 'last_name', 'first_name', 'email', 'phone', 'is_active', 'actions'];
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
|
||||
constructor(
|
||||
private teacherService: TeacherService,
|
||||
private dialog: MatDialog
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadTeachers();
|
||||
}
|
||||
|
||||
loadTeachers(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.teachers$ = this.teacherService.getTeachers();
|
||||
|
||||
this.teachers$.subscribe({
|
||||
next: () => this.isLoading = false,
|
||||
error: (err) => {
|
||||
console.error('Error loading teachers:', err);
|
||||
this.error = 'Errore durante il caricamento degli insegnanti.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addTeacher(): void {
|
||||
const dialogRef = this.dialog.open(TeacherDialogComponent, {
|
||||
width: '600px', // Larghezza maggiore per più campi
|
||||
data: {} // Dati vuoti per la modalità aggiunta
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.teacherService.addTeacher(result).subscribe({
|
||||
next: () => this.loadTeachers(),
|
||||
error: (err) => {
|
||||
console.error('Error adding teacher:', err);
|
||||
this.error = 'Errore durante l\'aggiunta dell\'insegnante.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editTeacher(teacher: Teacher): void {
|
||||
// Recupera i dati completi dell'insegnante prima di aprire il dialog
|
||||
// perché la tabella potrebbe mostrare solo dati parziali
|
||||
this.teacherService.getTeacher(teacher.id).subscribe({
|
||||
next: (fullTeacherData) => {
|
||||
const dialogRef = this.dialog.open(TeacherDialogComponent, {
|
||||
width: '600px',
|
||||
data: { teacher: fullTeacherData } // Passa i dati completi
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.isLoading = true;
|
||||
this.teacherService.updateTeacher(teacher.id, result).subscribe({
|
||||
next: () => this.loadTeachers(),
|
||||
error: (err) => {
|
||||
console.error(`Error updating teacher ${teacher.id}:`, err);
|
||||
this.error = 'Errore durante la modifica dell\'insegnante.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error fetching full teacher data for ID ${teacher.id}:`, err);
|
||||
this.error = 'Errore nel recuperare i dati completi per la modifica.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteTeacher(teacher: Teacher): void {
|
||||
const dialogData: ConfirmDialogData = {
|
||||
title: 'Conferma Eliminazione Insegnante',
|
||||
message: `Sei sicuro di voler eliminare l'insegnante "${teacher.first_name} ${teacher.last_name}" (ID: ${teacher.id})?`,
|
||||
confirmButtonText: 'Elimina'
|
||||
};
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
width: '400px',
|
||||
data: dialogData
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.isLoading = true;
|
||||
this.teacherService.deleteTeacher(teacher.id).subscribe({
|
||||
next: () => this.loadTeachers(),
|
||||
error: (err) => {
|
||||
console.error(`Error deleting teacher ${teacher.id}:`, err);
|
||||
this.error = 'Errore durante l\'eliminazione dell\'insegnante.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Gestisce il cambio di stato attivo/inattivo direttamente dalla tabella
|
||||
toggleActive(teacher: Teacher, event: MatSlideToggleChange): void {
|
||||
// event.stopPropagation(); // Non necessario/possibile con (change) di MatSlideToggleChange
|
||||
const updatedTeacher: TeacherInput = { is_active: !teacher.is_active };
|
||||
this.teacherService.updateTeacher(teacher.id, updatedTeacher).subscribe({
|
||||
next: () => this.loadTeachers(), // Ricarica per vedere lo stato aggiornato
|
||||
error: (err) => {
|
||||
console.error(`Error toggling active state for teacher ${teacher.id}:`, err);
|
||||
this.error = 'Errore durante l\'aggiornamento dello stato.';
|
||||
// Potrebbe essere utile ripristinare lo stato visivo del toggle in caso di errore
|
||||
this.loadTeachers();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
24
frontend/nursery-app/src/app/guards/auth.guard.ts
Normal file
24
frontend/nursery-app/src/app/guards/auth.guard.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service'; // Importa AuthService
|
||||
import { map, take } from 'rxjs/operators';
|
||||
|
||||
export const authGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
// Usa l'observable isAuthenticated$ per verificare lo stato
|
||||
return authService.isAuthenticated$.pipe(
|
||||
take(1), // Prende solo il valore corrente e completa
|
||||
map(isAuthenticated => {
|
||||
if (isAuthenticated) {
|
||||
return true; // Utente autenticato, permette accesso
|
||||
} else {
|
||||
// Utente non autenticato, reindirizza al login
|
||||
console.log('AuthGuard: User not authenticated, redirecting to login.');
|
||||
router.navigate(['/login']);
|
||||
return false; // Blocca accesso alla rotta
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
|
||||
intercept(
|
||||
req: HttpRequest<any>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<any>> {
|
||||
// Aggiungi gli header richiesti dal backend
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (token && !req.url.includes('/login')) {
|
||||
req = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'REQUEST_METHOD': this.getRequestMethod(req.method) // Aggiungi REQUEST_METHOD
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return next.handle(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
// Gestisci errori 401 (non autorizzato)
|
||||
if (error.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
// Reindirizza alla pagina di login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private getRequestMethod(method: string): string {
|
||||
// Mappa i metodi HTTP standard al formato richiesto dal backend
|
||||
const methodMap: { [key: string]: string } = {
|
||||
'GET': 'GET',
|
||||
'POST': 'POST',
|
||||
'PUT': 'PUT',
|
||||
'DELETE': 'DELETE',
|
||||
'PATCH': 'PATCH',
|
||||
'HEAD': 'HEAD',
|
||||
'OPTIONS': 'OPTIONS'
|
||||
};
|
||||
|
||||
return methodMap[method] || method;
|
||||
}
|
||||
}
|
||||
68
frontend/nursery-app/src/app/services/api.service.ts
Normal file
68
frontend/nursery-app/src/app/services/api.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
// @Injectable({
|
||||
// providedIn: 'root'
|
||||
// })
|
||||
export class ApiService {
|
||||
private apiUrl = 'http://localhost:8000/api';
|
||||
public controllerName: string;
|
||||
|
||||
|
||||
|
||||
constructor(private authService: AuthService, private http: HttpClient, controllerName: string) {
|
||||
this.controllerName = controllerName;
|
||||
}
|
||||
|
||||
// Metodo per ottenere l'header Authorization
|
||||
private getAuthHeaders(): HttpHeaders {
|
||||
const token = localStorage.getItem('nursery_auth_token');//this.authService.getToken();
|
||||
|
||||
|
||||
if (!token) {
|
||||
return new HttpHeaders();
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
private mergeHeaders(base: HttpHeaders, toAdd?: HttpHeaders): HttpHeaders {
|
||||
let merged = base;
|
||||
toAdd?.keys().forEach(key => {
|
||||
const values = toAdd.getAll(key); // Gestisce multipli valori per header
|
||||
values?.forEach(value => {
|
||||
merged = merged.append(key, value);
|
||||
});
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Esempio di chiamata GET con autenticazione
|
||||
get<T>(endpoint: string, params?: HttpParams): Observable<T> {
|
||||
const headers = this.getAuthHeaders();
|
||||
return this.http.get<T>(`${this.apiUrl}/${endpoint}`, { params, headers });
|
||||
}
|
||||
|
||||
// Esempio di chiamata POST con autenticazione
|
||||
post<T>(endpoint: string, body?: any, header?: HttpHeaders): Observable<T> {
|
||||
const headers = this.mergeHeaders(this.getAuthHeaders(), header);
|
||||
return this.http.post<T>(`${this.apiUrl}/${endpoint}`, body, { headers });
|
||||
}
|
||||
|
||||
// Esempio di chiamata PUT con autenticazione
|
||||
put<T>(endpoint: string, body?: any, header?: HttpHeaders): Observable<T> {
|
||||
const headers = this.mergeHeaders(this.getAuthHeaders(), header);
|
||||
return this.http.put<T>(`${this.apiUrl}/${endpoint}`, body, { headers });
|
||||
}
|
||||
|
||||
// Esempio di chiamata DELETE con autenticazione
|
||||
delete<T>(endpoint: string): Observable<T> {
|
||||
const headers = this.getAuthHeaders();
|
||||
return this.http.delete<T>(`${this.apiUrl}/${endpoint}`, { headers });
|
||||
}
|
||||
}
|
||||
110
frontend/nursery-app/src/app/services/auth.service.ts
Normal file
110
frontend/nursery-app/src/app/services/auth.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; // Aggiungi HttpParams
|
||||
import { BehaviorSubject, Observable, tap } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
// Interfaccia per i dati utente (dal payload del token o dalla risposta /api/me)
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'admin' | 'operator';
|
||||
structure_id?: number | null;
|
||||
}
|
||||
|
||||
// Interfaccia per la risposta del login
|
||||
interface LoginResponse {
|
||||
message: string;
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user: User;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private apiUrl = 'http://localhost:8000/api'; // URL base API
|
||||
private tokenKey = 'nursery_auth_token'; // Chiave per localStorage
|
||||
private userKey = 'nursery_auth_user'; // Chiave per localStorage
|
||||
|
||||
// BehaviorSubject per tenere traccia dello stato di autenticazione
|
||||
// Inizializza leggendo da localStorage per persistere lo stato tra refresh
|
||||
private isAuthenticatedSubject = new BehaviorSubject<boolean>(this.hasToken());
|
||||
isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
|
||||
|
||||
// BehaviorSubject per i dati dell'utente loggato
|
||||
private currentUserSubject = new BehaviorSubject<User | null>(this.getUserFromStorage());
|
||||
currentUser$ = this.currentUserSubject.asObservable();
|
||||
|
||||
constructor(private http: HttpClient, private router: Router) { }
|
||||
|
||||
// Metodo per effettuare il login
|
||||
login(credentials: { email: string, password: string }): Observable<LoginResponse> {
|
||||
// Usiamo x-www-form-urlencoded perché ha funzionato con curl
|
||||
const body = new HttpParams({ fromObject: credentials });
|
||||
const httpOptions = {
|
||||
headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' })
|
||||
};
|
||||
|
||||
return this.http.post<LoginResponse>(`${this.apiUrl}/login`, body.toString(), httpOptions).pipe(
|
||||
tap(response => {
|
||||
// Salva token e utente dopo login successo
|
||||
this.saveAuthData(response.access_token, response.user);
|
||||
this.isAuthenticatedSubject.next(true); // Aggiorna stato autenticazione
|
||||
this.currentUserSubject.next(response.user); // Aggiorna utente corrente
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Metodo per effettuare il logout
|
||||
logout(): void {
|
||||
this.removeAuthData(); // Rimuovi token e utente
|
||||
this.isAuthenticatedSubject.next(false); // Aggiorna stato
|
||||
this.currentUserSubject.next(null); // Aggiorna utente
|
||||
this.router.navigate(['/login']); // Reindirizza al login
|
||||
}
|
||||
|
||||
// Salva token e dati utente in localStorage
|
||||
private saveAuthData(token: string, user: User): void {
|
||||
localStorage.setItem(this.tokenKey, token);
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
}
|
||||
|
||||
// Rimuove token e dati utente da localStorage
|
||||
private removeAuthData(): void {
|
||||
localStorage.removeItem(this.tokenKey);
|
||||
localStorage.removeItem(this.userKey);
|
||||
}
|
||||
|
||||
// Recupera il token da localStorage
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(this.tokenKey);
|
||||
}
|
||||
|
||||
// Verifica se un token è presente
|
||||
private hasToken(): boolean {
|
||||
return !!this.getToken();
|
||||
}
|
||||
|
||||
// Recupera i dati utente da localStorage
|
||||
private getUserFromStorage(): User | null {
|
||||
const userString = localStorage.getItem(this.userKey);
|
||||
return userString ? JSON.parse(userString) : null;
|
||||
}
|
||||
|
||||
// Metodo per ottenere i dati dell'utente corrente (utile per i componenti)
|
||||
getCurrentUser(): User | null {
|
||||
return this.currentUserSubject.value;
|
||||
}
|
||||
|
||||
// Metodo per verificare se l'utente ha un certo ruolo
|
||||
hasRole(role: 'admin' | 'operator'): boolean {
|
||||
const user = this.getCurrentUser();
|
||||
return !!user && user.role === role;
|
||||
}
|
||||
|
||||
// TODO: Implementare refresh token se necessario
|
||||
// TODO: Implementare chiamata a /api/me per verificare validità token all'avvio app?
|
||||
}
|
||||
79
frontend/nursery-app/src/app/services/child.service.ts
Normal file
79
frontend/nursery-app/src/app/services/child.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
// Interfaccia per i dati del bambino
|
||||
export interface Child {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
date_of_birth: string; // YYYY-MM-DD
|
||||
enrollment_date: string; // YYYY-MM-DD
|
||||
structure_id?: number | null;
|
||||
parent1_name?: string | null;
|
||||
parent1_phone?: string | null;
|
||||
parent1_email?: string | null;
|
||||
parent2_name?: string | null;
|
||||
parent2_phone?: string | null;
|
||||
parent2_email?: string | null;
|
||||
address?: string | null;
|
||||
city?: string | null;
|
||||
notes?: string | null;
|
||||
is_active: boolean; // O number
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Campi joinati (se presenti, come structure_name)
|
||||
structure_name?: string | null;
|
||||
}
|
||||
|
||||
// Interfaccia per i dati di input
|
||||
export type ChildInput = Partial<Omit<Child, 'id' | 'created_at' | 'updated_at' | 'structure_name'>>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ChildService extends ApiService {
|
||||
|
||||
constructor(authService: AuthService, http: HttpClient) {
|
||||
super(authService, http, 'children');
|
||||
}
|
||||
|
||||
// GET /api/children (con filtri opzionali)
|
||||
getChildren(filters: { structure_id?: number, is_active?: boolean } = {}): Observable<Child[]> {
|
||||
let params = new HttpParams();
|
||||
if (filters.structure_id !== undefined && filters.structure_id !== null) {
|
||||
params = params.set('structure_id', filters.structure_id.toString());
|
||||
}
|
||||
if (filters.is_active !== undefined && filters.is_active !== null) {
|
||||
params = params.set('is_active', filters.is_active.toString()); // Invia 'true' o 'false' come stringa
|
||||
}
|
||||
|
||||
return this.get<Child[]>(`${this.controllerName}`, params);
|
||||
// Il backend restituisce già dati parziali per la lista
|
||||
//return this.http.get<Child[]>(this.apiUrl, { params });
|
||||
}
|
||||
|
||||
// GET /api/children/{id}
|
||||
getChild(id: number): Observable<Child> {
|
||||
return this.get<Child>(`${this.controllerName}/${id}`); // Recupera dati completi
|
||||
}
|
||||
|
||||
// POST /api/children
|
||||
addChild(child: ChildInput): Observable<Child> {
|
||||
// Invia come JSON
|
||||
return this.post<Child>(`${this.controllerName}`, child);
|
||||
}
|
||||
|
||||
// PUT /api/children/{id}
|
||||
updateChild(id: number, child: ChildInput): Observable<Child> {
|
||||
// Invia come JSON
|
||||
return this.put<Child>(`${this.controllerName}/${id}`, child);
|
||||
}
|
||||
|
||||
// DELETE /api/children/{id}
|
||||
deleteChild(id: number): Observable<null> {
|
||||
return this.delete<null>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
}
|
||||
58
frontend/nursery-app/src/app/services/school-year.service.ts
Normal file
58
frontend/nursery-app/src/app/services/school-year.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
// Interfaccia per i dati dell'anno scolastico
|
||||
export interface SchoolYear {
|
||||
id: number;
|
||||
name: string;
|
||||
start_date: string; // Formato YYYY-MM-DD
|
||||
end_date: string; // Formato YYYY-MM-DD
|
||||
is_active: boolean; // O number
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Interfaccia per i dati parziali usati in creazione/modifica
|
||||
export type SchoolYearInput = Partial<Omit<SchoolYear, 'id' | 'created_at' | 'updated_at'>>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SchoolYearService extends ApiService {
|
||||
|
||||
//private apiUrl = 'http://localhost:8000/api/school-years';
|
||||
|
||||
private httpOptions = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' });
|
||||
|
||||
constructor(authService: AuthService, http: HttpClient) {
|
||||
super(authService, http, 'school-years');
|
||||
}
|
||||
|
||||
// GET /api/school-years
|
||||
getSchoolYears(): Observable<SchoolYear[]> {
|
||||
return this.get<SchoolYear[]>(`${this.controllerName}`);
|
||||
}
|
||||
|
||||
// GET /api/school-years/{id}
|
||||
getSchoolYear(id: number): Observable<SchoolYear> {
|
||||
return this.get<SchoolYear>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
|
||||
// POST /api/school-years
|
||||
addSchoolYear(schoolYear: SchoolYearInput): Observable<SchoolYear> {
|
||||
return this.post<SchoolYear>(`${this.controllerName}`, schoolYear);
|
||||
}
|
||||
|
||||
// PUT /api/school-years/{id}
|
||||
updateSchoolYear(id: number, schoolYear: SchoolYearInput): Observable<SchoolYear> {
|
||||
return this.put<SchoolYear>(`${this.controllerName}/${id}`, schoolYear);
|
||||
}
|
||||
|
||||
// DELETE /api/school-years/{id}
|
||||
deleteSchoolYear(id: number): Observable<null> {
|
||||
return this.delete<null>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
// Interfaccia per i dati della definizione turno
|
||||
export interface ShiftDefinition {
|
||||
id: number;
|
||||
name: string;
|
||||
start_time: string; // Formato HH:MM:SS o HH:MM
|
||||
end_time: string; // Formato HH:MM:SS o HH:MM
|
||||
notes?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Interfaccia per i dati di input
|
||||
export type ShiftDefinitionInput = Partial<Omit<ShiftDefinition, 'id' | 'created_at' | 'updated_at'>>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ShiftDefinitionService extends ApiService {
|
||||
|
||||
constructor(authService: AuthService, http: HttpClient) {
|
||||
super(authService, http, 'shift-definitions');
|
||||
}
|
||||
|
||||
// GET /api/shift-definitions
|
||||
getShiftDefinitions(): Observable<ShiftDefinition[]> {
|
||||
return this.get<ShiftDefinition[]>(`${this.controllerName}`);
|
||||
}
|
||||
|
||||
// GET /api/shift-definitions/{id}
|
||||
getShiftDefinition(id: number): Observable<ShiftDefinition> {
|
||||
return this.get<ShiftDefinition>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
|
||||
// POST /api/shift-definitions
|
||||
addShiftDefinition(shift: ShiftDefinitionInput): Observable<ShiftDefinition> {
|
||||
return this.post<ShiftDefinition>(`${this.controllerName}`, shift); // Invia come JSON
|
||||
}
|
||||
|
||||
// PUT /api/shift-definitions/{id}
|
||||
updateShiftDefinition(id: number, shift: ShiftDefinitionInput): Observable<ShiftDefinition> {
|
||||
return this.put<ShiftDefinition>(`${this.controllerName}/${id}`, shift); // Invia come JSON
|
||||
}
|
||||
|
||||
// DELETE /api/shift-definitions/{id}
|
||||
deleteShiftDefinition(id: number): Observable<null> {
|
||||
return this.delete<null>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
}
|
||||
57
frontend/nursery-app/src/app/services/structure.service.ts
Normal file
57
frontend/nursery-app/src/app/services/structure.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
// Interfaccia per tipizzare i dati della struttura
|
||||
export interface Structure {
|
||||
id: number;
|
||||
name: string;
|
||||
address?: string | null;
|
||||
city?: string | null;
|
||||
province?: string | null;
|
||||
zip_code?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
notes?: string | null;
|
||||
created_at: string; // O Date se preferisci fare il parsing
|
||||
updated_at: string; // O Date
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class StructureService extends ApiService {
|
||||
|
||||
constructor(authService: AuthService, http: HttpClient) {
|
||||
super(authService, http, 'structures');
|
||||
}
|
||||
|
||||
// GET /api/structures
|
||||
getStructures(): Observable<Structure[]> {
|
||||
return this.get<Structure[]>(`${this.controllerName}`);
|
||||
}
|
||||
|
||||
// GET /api/structures/{id}
|
||||
getStructure(id: number): Observable<Structure> {
|
||||
return this.get<Structure>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
|
||||
// POST /api/structures
|
||||
addStructure(structure: Partial<Structure>): Observable<Structure> {
|
||||
return this.post<Structure>(`${this.controllerName}`, structure);
|
||||
}
|
||||
|
||||
// PUT /api/structures/{id}
|
||||
updateStructure(id: number, structure: Partial<Structure>): Observable<Structure> {
|
||||
return this.put<Structure>(`${this.controllerName}/${id}`, structure);
|
||||
}
|
||||
|
||||
// DELETE /api/structures/{id}
|
||||
deleteStructure(id: number): Observable<null> { // DELETE restituisce 204 No Content
|
||||
// Specificare responseType: 'text' può aiutare se il backend non restituisce JSON valido su DELETE
|
||||
// ma dato che restituisce 204, Observable<null> è appropriato.
|
||||
return this.delete<null>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
// Interfaccia per i dati del contratto (inclusi campi joinati)
|
||||
export interface TeacherContract {
|
||||
id: number;
|
||||
teacher_id: number;
|
||||
school_year_id: number;
|
||||
structure_id?: number | null; // Opzionale
|
||||
contract_type?: string | null;
|
||||
start_date: string; // YYYY-MM-DD
|
||||
end_date?: string | null; // YYYY-MM-DD
|
||||
weekly_hours?: number | null;
|
||||
salary?: number | null;
|
||||
notes?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Campi joinati (aggiunti dal backend)
|
||||
teacher_first_name?: string;
|
||||
teacher_last_name?: string;
|
||||
school_year_name?: string;
|
||||
structure_name?: string | null;
|
||||
}
|
||||
|
||||
// Interfaccia per i dati di input (senza ID e campi readonly/joinati)
|
||||
export type TeacherContractInput = Partial<Omit<TeacherContract,
|
||||
'id' | 'created_at' | 'updated_at' |
|
||||
'teacher_first_name' | 'teacher_last_name' | 'school_year_name' | 'structure_name'
|
||||
>>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TeacherContractService extends ApiService {
|
||||
|
||||
// Non più necessario con application/json (è il default di HttpClient per POST/PUT con oggetti)
|
||||
// private httpOptions = {
|
||||
// headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' })
|
||||
// };
|
||||
|
||||
constructor(authService: AuthService, http: HttpClient) {
|
||||
super(authService, http, 'teacher-contracts');
|
||||
}
|
||||
|
||||
// GET /api/teacher-contracts (con filtri opzionali)
|
||||
getTeacherContracts(filters: { teacher_id?: number, school_year_id?: number, structure_id?: number } = {}): Observable<TeacherContract[]> {
|
||||
let params = new HttpParams();
|
||||
if (filters.teacher_id) {
|
||||
params = params.set('teacher_id', filters.teacher_id.toString());
|
||||
}
|
||||
if (filters.school_year_id) {
|
||||
params = params.set('school_year_id', filters.school_year_id.toString());
|
||||
}
|
||||
if (filters.structure_id) {
|
||||
params = params.set('structure_id', filters.structure_id.toString());
|
||||
}
|
||||
return this.get<TeacherContract[]>(`${this.controllerName}`, params);
|
||||
}
|
||||
|
||||
// GET /api/teacher-contracts/{id}
|
||||
getTeacherContract(id: number): Observable<TeacherContract> {
|
||||
return this.get<TeacherContract>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
|
||||
// POST /api/teacher-contracts
|
||||
addTeacherContract(contract: TeacherContractInput): Observable<TeacherContract> {
|
||||
// Invia l'oggetto direttamente, HttpClient lo serializzerà come JSON
|
||||
return this.post<TeacherContract>(`${this.controllerName}`, contract); // Rimosso .toString() e httpOptions
|
||||
}
|
||||
|
||||
// PUT /api/teacher-contracts/{id}
|
||||
updateTeacherContract(id: number, contract: TeacherContractInput): Observable<TeacherContract> {
|
||||
// Invia l'oggetto direttamente
|
||||
return this.put<TeacherContract>(`${this.controllerName}/${id}`, contract); // Rimosso .toString() e httpOptions
|
||||
}
|
||||
|
||||
// DELETE /api/teacher-contracts/{id}
|
||||
deleteTeacherContract(id: number): Observable<null> {
|
||||
return this.delete<null>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
}
|
||||
59
frontend/nursery-app/src/app/services/teacher.service.ts
Normal file
59
frontend/nursery-app/src/app/services/teacher.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
// Interfaccia per i dati dell'insegnante
|
||||
export interface Teacher {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
date_of_birth?: string | null; // Manteniamo stringa per semplicità, potremmo usare Date
|
||||
hire_date?: string | null;
|
||||
qualifications?: string | null;
|
||||
is_active: boolean; // O number se il backend restituisce 0/1
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Interfaccia per i dati parziali usati in creazione/modifica
|
||||
export type TeacherInput = Partial<Omit<Teacher, 'id' | 'created_at' | 'updated_at'>>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TeacherService extends ApiService {
|
||||
|
||||
constructor(authService: AuthService, http: HttpClient) {
|
||||
super(authService, http, 'teachers');
|
||||
}
|
||||
|
||||
// GET /api/teachers
|
||||
getTeachers(): Observable<Teacher[]> {
|
||||
// Il backend restituisce già un array ridotto per la lista
|
||||
return this.get<Teacher[]>(`${this.controllerName}`);
|
||||
}
|
||||
|
||||
// GET /api/teachers/{id}
|
||||
getTeacher(id: number): Observable<Teacher> {
|
||||
return this.get<Teacher>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
|
||||
// POST /api/teachers
|
||||
addTeacher(teacher: TeacherInput): Observable<Teacher> {
|
||||
return this.post<Teacher>(`${this.controllerName}`, teacher);
|
||||
}
|
||||
|
||||
// PUT /api/teachers/{id}
|
||||
updateTeacher(id: number, teacher: TeacherInput): Observable<Teacher> {
|
||||
return this.put<Teacher>(`${this.controllerName}/${id}`, teacher);
|
||||
}
|
||||
|
||||
// DELETE /api/teachers/{id}
|
||||
deleteTeacher(id: number): Observable<null> {
|
||||
return this.delete<null>(`${this.controllerName}/${id}`);
|
||||
}
|
||||
}
|
||||
15
frontend/nursery-app/src/index.html
Normal file
15
frontend/nursery-app/src/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>NurseryApp</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body class="mat-typography">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/nursery-app/src/main.ts
Normal file
6
frontend/nursery-app/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
4
frontend/nursery-app/src/styles.scss
Normal file
4
frontend/nursery-app/src/styles.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
|
||||
Reference in New Issue
Block a user