Primo rilascio

This commit is contained in:
2026-03-07 00:15:59 +01:00
commit dd5282dd69
609 changed files with 75246 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
frontend/nursery-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
frontend/nursery-app/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

View File

@@ -0,0 +1,59 @@
# NurseryApp
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.5.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -0,0 +1,104 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"nursery-app": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/nursery-app",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "nursery-app:build:production"
},
"development": {
"buildTarget": "nursery-app:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.scss"
],
"scripts": []
}
}
}
}
}
}

14485
frontend/nursery-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"name": "nursery-app",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/cdk": "^19.2.9",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/material": "^19.2.9",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.5",
"@angular/cli": "^19.2.5",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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>

View 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
}

View 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');
});
});

View 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();
}
});
}
}

View 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
}
]
};

View 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
];

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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();
});
});

View File

@@ -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
}
});
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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;
}
});
}
});
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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 */
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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();
});
});

View File

@@ -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();
}
});
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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;
}
});
}
});
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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;
}
});
}
});
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
// }
}

View File

@@ -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>

View File

@@ -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 */
}

View File

@@ -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();
});
});

View File

@@ -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();
}
});
}
}

View 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
}
})
);
};

View File

@@ -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;
}
}

View 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 });
}
}

View 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?
}

View 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}`);
}
}

View 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}`);
}
}

View File

@@ -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}`);
}
}

View 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}`);
}
}

View File

@@ -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}`);
}
}

View 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}`);
}
}

View 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>

View 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));

View 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; }

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,27 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}