272 lines
8.1 KiB
TypeScript
272 lines
8.1 KiB
TypeScript
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import {
|
|
BehaviorSubject,
|
|
catchError,
|
|
combineLatest,
|
|
debounceTime,
|
|
distinctUntilChanged,
|
|
map,
|
|
Observable,
|
|
of,
|
|
retry,
|
|
Subject,
|
|
takeUntil,
|
|
startWith
|
|
} from 'rxjs';
|
|
import { HttpErrorResponse } from '@angular/common/http';
|
|
import { ComponentType } from '@angular/cdk/portal';
|
|
|
|
import { Qualification } from '../Qualification';
|
|
import QualificationService from '../../services/qualification.service';
|
|
import { CreateComponent } from '../create/create.component';
|
|
import { EditComponent } from '../edit/edit.component';
|
|
import { DeleteComponent } from '../delete/delete.component';
|
|
import { DetailsComponent } from '../details/details.component';
|
|
|
|
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
|
import { MatSnackBar, MatSnackBarConfig, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
import { MatCardModule } from '@angular/material/card';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { MatDividerModule } from '@angular/material/divider';
|
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
import { MatMenuModule } from '@angular/material/menu';
|
|
import { MatTableModule } from '@angular/material/table';
|
|
import { MatSortModule } from '@angular/material/sort';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
|
|
class SearchEngine {
|
|
static readonly DEBOUNCE_TIME = 300;
|
|
|
|
constructor(
|
|
private readonly items: Qualification[],
|
|
private readonly searchTerm: string
|
|
) {}
|
|
|
|
execute(): Qualification[] {
|
|
if (!this.searchTerm?.trim()) {
|
|
return this.items;
|
|
}
|
|
const normalizedTerm = this.searchTerm.toLowerCase().trim();
|
|
return this.items.filter(item =>
|
|
item.skill?.toLowerCase().includes(normalizedTerm)
|
|
);
|
|
}
|
|
}
|
|
|
|
class ErrorHandler {
|
|
private readonly config: MatSnackBarConfig = {
|
|
duration: 5000,
|
|
horizontalPosition: 'end',
|
|
verticalPosition: 'bottom',
|
|
panelClass: ['error-snackbar']
|
|
};
|
|
|
|
constructor(private readonly snackBar: MatSnackBar) {}
|
|
|
|
handleHttpError(error: HttpErrorResponse): Observable<never[]> {
|
|
console.error('API Error:', error);
|
|
this.showError('Failed to load qualifications. Please try again.');
|
|
return of([]);
|
|
}
|
|
|
|
showError(message: string): void {
|
|
this.snackBar.open(message, 'Close', this.config);
|
|
}
|
|
}
|
|
|
|
class DialogManager {
|
|
constructor(
|
|
private readonly dialog: MatDialog,
|
|
private readonly errorHandler: ErrorHandler
|
|
) {}
|
|
|
|
openDialog<T>(
|
|
component: ComponentType<any>,
|
|
data?: T
|
|
): MatDialogRef<any, boolean> {
|
|
return this.dialog.open(component, { data });
|
|
}
|
|
}
|
|
|
|
interface QualificationState {
|
|
items: Qualification[];
|
|
filteredItems: Qualification[];
|
|
searchTerm: string;
|
|
isLoading: boolean;
|
|
isSearching: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
class QualificationStore {
|
|
private readonly initialState: QualificationState = {
|
|
items: [],
|
|
filteredItems: [],
|
|
searchTerm: '',
|
|
isLoading: false,
|
|
isSearching: false,
|
|
error: null
|
|
};
|
|
|
|
private readonly state = new BehaviorSubject<QualificationState>(this.initialState);
|
|
private readonly searchTerm$ = new Subject<string>();
|
|
|
|
readonly items$ = this.state.pipe(map(state => state.filteredItems));
|
|
readonly isLoading$ = this.state.pipe(map(state => state.isLoading));
|
|
readonly isSearching$ = this.state.pipe(map(state => state.isSearching));
|
|
readonly error$ = this.state.pipe(map(state => state.error));
|
|
|
|
constructor(
|
|
private readonly api: QualificationService,
|
|
private readonly errorHandler: ErrorHandler
|
|
) {
|
|
this.setupSearch();
|
|
}
|
|
|
|
private setupSearch(): void {
|
|
this.searchTerm$.pipe(
|
|
startWith(''),
|
|
debounceTime(SearchEngine.DEBOUNCE_TIME),
|
|
distinctUntilChanged()
|
|
).subscribe(term => {
|
|
this.state.next({
|
|
...this.state.value,
|
|
isSearching: true,
|
|
searchTerm: term
|
|
});
|
|
|
|
const currentState = this.state.value;
|
|
const engine = new SearchEngine(currentState.items, term);
|
|
const filteredItems = engine.execute();
|
|
|
|
setTimeout(() => {
|
|
this.state.next({
|
|
...currentState,
|
|
searchTerm: term,
|
|
filteredItems,
|
|
isSearching: false
|
|
});
|
|
}, 150);
|
|
});
|
|
}
|
|
|
|
load(): void {
|
|
this.setLoading(true);
|
|
this.api.getAll().pipe(
|
|
retry(3),
|
|
catchError(error => this.errorHandler.handleHttpError(error))
|
|
).subscribe(items => {
|
|
const engine = new SearchEngine(items, this.state.value.searchTerm);
|
|
this.state.next({
|
|
...this.state.value,
|
|
items,
|
|
filteredItems: engine.execute(),
|
|
isLoading: false,
|
|
error: null
|
|
});
|
|
});
|
|
}
|
|
|
|
search(term: string): void {
|
|
this.searchTerm$.next(term);
|
|
}
|
|
|
|
private setLoading(isLoading: boolean): void {
|
|
this.state.next({ ...this.state.value, isLoading });
|
|
}
|
|
|
|
complete(): void {
|
|
this.state.complete();
|
|
this.searchTerm$.complete();
|
|
}
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-qualifications',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
MatCardModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatProgressSpinnerModule,
|
|
MatSnackBarModule,
|
|
MatDividerModule,
|
|
MatTooltipModule,
|
|
MatMenuModule,
|
|
MatTableModule,
|
|
MatSortModule,
|
|
MatFormFieldModule,
|
|
MatInputModule
|
|
],
|
|
templateUrl: './table.component.html',
|
|
styleUrl: './table.component.css'
|
|
})
|
|
export class QualificationsComponent implements OnInit, OnDestroy {
|
|
private readonly errorHandler = new ErrorHandler(inject(MatSnackBar));
|
|
private readonly dialogManager = new DialogManager(
|
|
inject(MatDialog),
|
|
this.errorHandler
|
|
);
|
|
private readonly store = new QualificationStore(
|
|
inject(QualificationService),
|
|
this.errorHandler
|
|
);
|
|
|
|
private readonly destroy$ = new Subject<void>();
|
|
|
|
protected readonly qualifications$ = this.store.items$;
|
|
protected readonly isLoading$ = this.store.isLoading$;
|
|
protected readonly isSearching$ = this.store.isSearching$;
|
|
protected readonly error$ = this.store.error$;
|
|
protected readonly displayedColumns = ['skill', 'actions'] as const;
|
|
|
|
ngOnInit(): void {
|
|
this.loadQualifications();
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
this.store.complete();
|
|
}
|
|
|
|
protected onSearch(event: Event): void {
|
|
const searchInput = event.target as HTMLInputElement;
|
|
this.store.search(searchInput.value);
|
|
}
|
|
|
|
protected createQualification(): void {
|
|
this.openModalAndReload(CreateComponent);
|
|
}
|
|
|
|
protected editQualification(qualification: Qualification): void {
|
|
this.openModalAndReload(EditComponent, qualification);
|
|
}
|
|
|
|
protected deleteQualification(id: number): void {
|
|
this.openModalAndReload(DeleteComponent, id);
|
|
}
|
|
|
|
protected viewQualificationDetails(qualification: Qualification): void {
|
|
this.dialogManager.openDialog(DetailsComponent, qualification);
|
|
}
|
|
|
|
private loadQualifications(): void {
|
|
this.store.load();
|
|
}
|
|
|
|
private openModalAndReload<T>(component: ComponentType<any>, data?: T): void {
|
|
const dialogRef = this.dialogManager.openDialog(component, data);
|
|
dialogRef.afterClosed()
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe(success => {
|
|
if (success === true) {
|
|
this.loadQualifications();
|
|
}
|
|
});
|
|
}
|
|
} |