WIP: style: adjust icon sizes in HTML templates #39

Closed
jleibl wants to merge 35 commits from task/adjust-icon-sizes into main
58 changed files with 1810 additions and 124 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -5,7 +5,7 @@ volumes:
services:
postgres-employee:
container_name: postgres_employee
container_name: ems-db
image: postgres:13.3
volumes:
- employee_postgres_data:/var/lib/postgresql/data
@ -17,9 +17,8 @@ services:
- "5432:5432"
employee:
container_name: employee
container_name: ems-api
image: berndheidemann/employee-management-service:1.1.3
# image: berndheidemann/employee-management-service_without_keycloak:1.1
environment:
spring.datasource.url: jdbc:postgresql://postgres-employee:5432/employee_db
spring.datasource.username: employee

View File

@ -20,8 +20,10 @@
"@angular/platform-browser": "^19.0.4",
"@angular/platform-browser-dynamic": "^19.0.4",
"@angular/router": "^19.0.4",
"keycloak-angular": "^16.1.0",
"rxjs": "~7.8.1",
"tailwind": "4.0.0",
"tailwindcss": "^3.4.17",
"tslib": "^2.8.1",
"zone.js": "~0.15.0"
},
@ -30,12 +32,14 @@
"@angular/cli": "^19.0.5",
"@angular/compiler-cli": "^19.0.4",
"@types/jasmine": "~5.1.5",
"autoprefixer": "^10.4.20",
"jasmine-core": "~5.2.0",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.4.49",
"typescript": "~5.5.4"
}
}

View File

@ -0,0 +1,7 @@
<html>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>

View File

@ -1,10 +0,0 @@
export class Employee {
constructor(public id?: number,
public lastName?: string,
public firstName?: string,
public street?: string,
public postcode?: string,
public city?: string,
public phone?: string) {
}
}

View File

@ -1,2 +1,5 @@
<app-employee-list></app-employee-list>
<main class="container mx-auto px-4 py-8 max-w-6xl">
<h1 class="text-3xl font-bold text-gray-900 mb-8">{{ title }}</h1>
<router-outlet></router-outlet>
</main>

View File

@ -1,29 +0,0 @@
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 'lf10StarterNew' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('lf10StarterNew');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, lf10StarterNew');
});
});

View File

@ -1,14 +1,14 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import {EmployeeListComponent} from "./employee-list/employee-list.component";
import {Component} from '@angular/core';
import {CommonModule} from '@angular/common';
import {RouterOutlet} from '@angular/router';
@Component({
selector: 'app-root',
imports: [CommonModule, EmployeeListComponent],
imports: [CommonModule, RouterOutlet],
templateUrl: './app.component.html',
standalone: true,
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'lf10StarterNew';
title = 'Employee Management System';
}

View File

@ -1,10 +1,50 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import {APP_INITIALIZER, ApplicationConfig} from '@angular/core';
import {provideRouter} from '@angular/router';
import {KeycloakAngularModule, KeycloakBearerInterceptor, KeycloakService} from "keycloak-angular";
import {routes} from './app.routes';
import {HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi} from "@angular/common/http";
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';
import {provideHttpClient, withInterceptorsFromDi} from "@angular/common/http";
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
export const initializeKeycloak = (keycloak: KeycloakService) => async () =>
keycloak.init({
config: {
url: 'https://keycloak.szut.dev/auth',
realm: 'szut',
clientId: 'employee-management-service-frontend',
},
loadUserProfileAtStartUp: true,
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri:
window.location.origin + '/silent-check-sso.html',
checkLoginIframe: false,
redirectUri: 'http://localhost:4200',
},
});
function initializeApp(keycloak: KeycloakService): () => Promise<boolean> {
return () => initializeKeycloak(keycloak)();
}
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideHttpClient(withInterceptorsFromDi()), provideAnimationsAsync()]
providers: [
provideRouter(routes),
provideHttpClient(withInterceptorsFromDi()),
provideAnimationsAsync(),
KeycloakAngularModule,
{
provide: APP_INITIALIZER,
useFactory: initializeApp,
multi: true,
deps: [KeycloakService]
},
KeycloakService,
provideHttpClient(withInterceptorsFromDi()),
{
provide: HTTP_INTERCEPTORS,
useClass: KeycloakBearerInterceptor,
multi: true
}
]
};

View File

@ -1,3 +1,10 @@
import { Routes } from '@angular/router';
import {Routes} from '@angular/router';
import {LoginComponent} from "./login/login.component";
import {AuthGuardService} from "./services/auth-guard.service";
import {HomeComponent} from "./home/home.component";
export const routes: Routes = [];
export const routes: Routes = [
{path: 'login', component: LoginComponent},
{path: '', component: HomeComponent, canActivate: [AuthGuardService]},
{path: '**', redirectTo: ''}
];

View File

@ -1,9 +0,0 @@
<h1>LF10-Starter</h1>
Wenn Sie in der EmployeeListComponent.ts ein gültiges Bearer-Token eintragen, sollten hier die Namen der in der Datenbank gespeicherten Mitarbeiter angezeigt werden!
<ul>
@for(e of employees$ | async; track e.id) {
<li>
{{e.lastName }}, {{e.firstName}}
</li>
}
</ul>

View File

@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EmployeeListComponent } from './employee-list.component';
describe('EmployeeListComponent', () => {
let component: EmployeeListComponent;
let fixture: ComponentFixture<EmployeeListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EmployeeListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(EmployeeListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,29 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import {Observable, of} from "rxjs";
import {HttpClient, HttpHeaders} from "@angular/common/http";
import {Employee} from "../Employee";
@Component({
selector: 'app-employee-list',
imports: [CommonModule],
templateUrl: './employee-list.component.html',
styleUrl: './employee-list.component.css'
})
export class EmployeeListComponent {
bearer = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIzUFQ0dldiNno5MnlQWk1EWnBqT1U0RjFVN0lwNi1ELUlqQWVGczJPbGU0In0.eyJleHAiOjE3MzM5MTQ5MjgsImlhdCI6MTczMzkxMTMyOCwianRpIjoiMjNhYzMwMmUtYmYxNS00OTRmLWJhYTItNjIzODllYWZkMmZhIiwiaXNzIjoiaHR0cHM6Ly9rZXljbG9hay5zenV0LmRldi9hdXRoL3JlYWxtcy9zenV0IiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjU1NDZjZDIxLTk4NTQtNDMyZi1hNDY3LTRkZTNlZWRmNTg4OSIsInR5cCI6IkJlYXJlciIsImF6cCI6ImVtcGxveWVlLW1hbmFnZW1lbnQtc2VydmljZSIsInNlc3Npb25fc3RhdGUiOiI2ODdiMTEwYS00NTRjLTQwMzgtYjBkMS1kZDAzZGQ1N2JiNjEiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbImh0dHA6Ly9sb2NhbGhvc3Q6NDIwMCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsicHJvZHVjdF9vd25lciIsIm9mZmxpbmVfYWNjZXNzIiwiZGVmYXVsdC1yb2xlcy1zenV0IiwidW1hX2F1dGhvcml6YXRpb24iLCJ1c2VyIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInByZWZlcnJlZF91c2VybmFtZSI6InVzZXIifQ.E5ir1Z-POpUU_jvTh8CzoMYO74qo_7uQXw7QQBUvXB2_37pT3_tutAq6sM4V5cNBu--fWar5bltlNcOAWd_7Kdb66Qc23i0RR9vPneoSduJAzoD8gtFbx8c7ltNR4pG-c6tdnkGhLLqM621DShaSlH8Shp-Z0-y4Iq3GFdQrAFH1CrRVYlW0qFv1EZsE9BmhW3hJwrR1S2IPiEN6MwhehLflLa_ZgLcF417ocIfK-6gbbRNAwXA-JajFVOZAEVXs-52Ta9Kb_EEQFpRsjXorfflmbizQmgrbhBUB7MTiPYIcRruZSYdfmjcE008PHnut52cTcVYEuOrUCUqY4VmhoQ';
employees$: Observable<Employee[]>;
constructor(private http: HttpClient) {
this.employees$ = of([]);
this.fetchData();
}
fetchData() {
this.employees$ = this.http.get<Employee[]>('/backend/employees', {
headers: new HttpHeaders()
.set('Content-Type', 'application/json')
.set('Authorization', `Bearer ${this.bearer}`)
});
}
}

View File

@ -0,0 +1,15 @@
import {Qualification} from "../qualification/Qualification";
export class Employee {
constructor(
public id?: number,
public lastName?: string,
public firstName?: string,
public street?: string,
public postcode?: string,
public city?: string,
public phone?: string,
public skillSet?: Qualification[]
) {
}
}

View File

@ -0,0 +1,78 @@
<h2 mat-dialog-title>Create Employee</h2>
<mat-dialog-content>
<form *ngIf="employeeForm" [formGroup]="employeeForm" (ngSubmit)="submit()">
<div class="!space-y-4">
<div class="flex gap-x-4">
<mat-form-field class="!w-full">
<mat-label>First Name</mat-label>
<input matInput formControlName="firstName" required>
<mat-hint>Enter the first name</mat-hint>
<mat-error *ngIf="errors['firstName']">{{errors['firstName']}}</mat-error>
</mat-form-field>
<mat-form-field class="!w-full">
<mat-label>Last Name</mat-label>
<input matInput formControlName="lastName" required>
<mat-hint>Enter the last name</mat-hint>
<mat-error *ngIf="errors['lastName']">{{errors['lastName']}}</mat-error>
</mat-form-field>
</div>
<mat-form-field class="!w-full">
<mat-label>Street</mat-label>
<input matInput formControlName="street" required>
<mat-hint>Enter the street address</mat-hint>
<mat-error *ngIf="errors['street']">{{errors['street']}}</mat-error>
</mat-form-field>
<div class="flex gap-x-4">
<mat-form-field class="!w-full">
<mat-label>City</mat-label>
<input matInput formControlName="city" required>
<mat-hint>Enter the city</mat-hint>
<mat-error *ngIf="errors['city']">{{errors['city']}}</mat-error>
</mat-form-field>
<mat-form-field class="!w-1/2">
<mat-label>Postcode</mat-label>
<input matInput formControlName="postcode" minlength="5" maxlength="5" type="number" required>
<mat-hint>Enter postcode</mat-hint>
<mat-error *ngIf="errors['postcode']">{{errors['postcode']}}</mat-error>
</mat-form-field>
</div>
<mat-form-field class="!w-full">
<mat-label>Phone</mat-label>
<input matInput formControlName="phone" required>
<mat-hint>Enter the phone number</mat-hint>
<mat-error *ngIf="errors['phone']">{{errors['phone']}}</mat-error>
</mat-form-field>
<mat-form-field class="!w-full">
<mat-label>Qualifications</mat-label>
<mat-hint>Select the qualifications</mat-hint>
<mat-select formControlName="qualifications" multiple>
<mat-option *ngFor="let qualification of qualifications" [value]="qualification.id">
{{qualification.skill}}
</mat-option>
</mat-select>
<mat-error *ngIf="errors['qualifications']">{{errors['qualifications']}}</mat-error>
</mat-form-field>
<mat-dialog-actions align="end" class="!px-0 !mb-0 flex flex-col sm:flex-row w-full gap-3">
<button mat-button
mat-dialog-close
class="text-sm md:text-base hover:bg-gray-100 py-2 px-6 rounded-md w-full sm:flex-1">
Cancel
</button>
<button mat-flat-button
color="primary"
type="submit"
class="!ml-0 text-sm md:text-base py-2 px-6 rounded-md w-full sm:flex-1">
Submit
</button>
</mat-dialog-actions>
</div>
</form>
</mat-dialog-content>

View File

@ -0,0 +1,119 @@
import {Component, inject, OnInit} from '@angular/core';
import {AbstractControl, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {MatFormField, MatHint, MatLabel} from "@angular/material/form-field";
import {MatError, MatInput} from "@angular/material/input";
import {MatButton} from "@angular/material/button";
import {
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import {Employee} from "../Employee";
import EmployeeApiService from "../../services/employee-api.service";
import {NgForOf, NgIf} from "@angular/common";
import {MatOption} from "@angular/material/core";
import {MatSelect} from "@angular/material/select";
import QualificationService from "../../services/qualification.service";
import {Qualification} from "../../qualification/Qualification";
import {debounceTime} from "rxjs";
@Component({
selector: 'app-create-employee',
imports: [
ReactiveFormsModule,
MatFormField,
MatInput,
MatButton,
MatLabel,
MatDialogContent,
MatDialogTitle,
MatDialogActions,
MatDialogClose,
NgIf,
MatOption,
MatSelect,
NgForOf,
MatError,
MatHint
],
templateUrl: './create.component.html',
standalone: true,
styleUrl: './create.component.css'
})
export class CreateComponent implements OnInit {
employeeForm!: FormGroup;
employeeService: EmployeeApiService = inject(EmployeeApiService);
formBuilder: FormBuilder = inject(FormBuilder);
dialogRef: MatDialogRef<CreateComponent> = inject(MatDialogRef);
qualificationService: QualificationService = inject(QualificationService);
qualifications: Qualification[] = [];
errorMsgs: { [key: string]: string } = {
firstName: 'First name is required',
lastName: 'Last name is required',
street: 'Street is required',
postcode: 'Postcode must be 5 characters long',
city: 'City is required',
phone: 'Phone is required',
qualifications: 'Qualifications are required'
}
errors: { [key: string]: string } = {}
ngOnInit(): void {
this.loadQualifications();
this.employeeForm = this.formBuilder.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
street: ['', Validators.required],
postcode: ['', [Validators.required, this.validatePostcode]],
city: ['', Validators.required],
phone: ['', Validators.required],
qualifications: [[]]
});
Object.keys(this.employeeForm.controls).forEach((controlName: string) => {
const control = this.employeeForm.controls[controlName];
control.valueChanges.pipe(debounceTime(10)).subscribe(() => {
this.showErrorMsg(controlName, control);
});
});
}
loadQualifications() {
this.qualificationService.getAll().subscribe(
qualifications => this.qualifications = qualifications
);
}
submit() {
if (!this.employeeForm.valid) {
return;
}
const formValue = this.employeeForm.value;
const employeeData = {
...formValue,
skillSet: formValue.qualifications
};
this.employeeService.create(employeeData as Employee).subscribe();
this.dialogRef.close(true);
}
showErrorMsg(controlName: string, control: AbstractControl | undefined) {
if (control?.errors) {
this.errors[controlName] = this.errorMsgs[controlName];
}
}
validatePostcode(control: AbstractControl) {
const postcode = control.value as number;
if (postcode.toString().length !== 5) {
return {invalidPostcode: true};
}
return null;
}
}

View File

@ -0,0 +1,33 @@
<h2 mat-dialog-title class="text-xl md:text-2xl font-semibold text-gray-800 mb-3 md:mb-4">Delete Employee</h2>
<mat-dialog-content class="!px-3 md:!px-6">
<div class="w-full min-w-[280px] md:min-w-[400px] space-y-4 md:space-y-6">
<div class="bg-amber-50 p-3 md:p-4 rounded-lg border border-amber-200">
<div class="flex items-start space-x-2 md:space-x-3">
<mat-icon class="text-amber-600 text-xl md:text-2xl !w-8 !h-8">warning</mat-icon>
<div>
<p class="text-gray-800 font-medium text-sm md:text-base">
Are you sure you want to delete {{employee.firstName}} {{employee.lastName}}?
</p>
<p class="text-gray-600 mt-1 text-xs md:text-sm">This action cannot be undone.</p>
</div>
</div>
</div>
<mat-dialog-actions align="end" class="!px-0 !mb-0 flex flex-col sm:flex-row w-full gap-3">
<button mat-button
[mat-dialog-close]="false"
class="text-sm md:text-base hover:bg-gray-100 py-2 px-6 rounded-md w-full sm:flex-1">
Cancel
</button>
<button mat-flat-button
color="warn"
(click)="deleteEmployee(employee.id ?? 0)"
mat-dialog-close
class="!ml-0 text-sm md:text-base py-2 px-6 rounded-md w-full sm:flex-1"
cdkFocusInitial>
Delete
</button>
</mat-dialog-actions>
</div>
</mat-dialog-content>

View File

@ -0,0 +1,38 @@
import {Component, Inject, inject} from '@angular/core';
import {Employee} from "../Employee";
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent, MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import {MatButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import EmployeeApiService from "../../services/employee-api.service";
@Component({
selector: 'app-delete-employee',
imports: [
MatDialogContent,
MatDialogTitle,
MatDialogActions,
MatButton,
MatDialogClose,
MatIcon
],
templateUrl: './delete.component.html',
standalone: true,
styleUrl: './delete.component.css'
})
export class DeleteComponent {
private apiService: EmployeeApiService = inject(EmployeeApiService);
private dialogRef: MatDialogRef<DeleteComponent> = inject(MatDialogRef);
protected employee: Employee = inject(MAT_DIALOG_DATA);
deleteEmployee(id: number) {
this.apiService.deleteById(id).subscribe();
this.dialogRef.close(true);
}
}

View File

@ -0,0 +1,68 @@
<h2 mat-dialog-title class="text-2xl font-semibold text-gray-800 mb-4">Employee Details</h2>
<mat-dialog-content class="!px-4 sm:!px-6">
<div class="w-full min-w-[280px] sm:min-w-[500px] space-y-6 sm:space-y-8">
<div class="flex flex-col sm:flex-row items-center sm:items-start text-center sm:text-left space-y-4 sm:space-y-0 sm:space-x-4">
<div class="h-20 w-20 sm:h-16 sm:w-16 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<span class="text-blue-600 text-2xl sm:text-xl font-medium">
{{ employee.firstName?.charAt(0) ?? '' }}{{ employee.lastName?.charAt(0) ?? '' }}
</span>
</div>
<div>
<h3 class="text-xl font-medium text-gray-900">{{ employee.firstName }} {{ employee.lastName }}</h3>
<p class="text-gray-500">ID: {{ employee.id }}</p>
</div>
</div>
<div class="space-y-3 sm:space-y-4">
<h4 class="text-lg font-medium text-gray-900 mb-2 sm:mb-3">Contact Information</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<p class="text-sm text-gray-500">Phone</p>
<p class="text-gray-900 break-all">{{ employee.phone }}</p>
</div>
</div>
</div>
<div class="space-y-3 sm:space-y-4">
<h4 class="text-lg font-medium text-gray-900 mb-2 sm:mb-3">Address</h4>
<div class="bg-gray-50 p-3 sm:p-4 rounded-lg space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div class="space-y-1">
<p class="text-sm text-gray-500">Street</p>
<p class="text-gray-900 break-words">{{ employee.street }}</p>
</div>
<div class="space-y-1">
<p class="text-sm text-gray-500">City</p>
<p class="text-gray-900">{{ employee.city }}</p>
</div>
</div>
<div class="space-y-1">
<p class="text-sm text-gray-500">Postcode</p>
<p class="text-gray-900">{{ employee.postcode }}</p>
</div>
</div>
</div>
<div class="space-y-3 sm:space-y-4">
<h4 class="text-lg font-medium text-gray-900 mb-2 sm:mb-3">Qualifications</h4>
<div class="flex flex-wrap gap-2">
@for (skill of employee.skillSet; track skill.id) {
<div class="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm font-medium">
{{skill.skill}}
</div>
} @empty {
<p class="text-gray-500 italic">No qualifications added</p>
}
</div>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" class="!px-4 sm:!px-6 !py-4 !mt-4 border-t">
<button mat-button
(click)="closeModal()"
class="text-sm md:text-base hover:bg-gray-100 py-2 px-6 rounded-md w-full">
Close
</button>
</mat-dialog-actions>

View File

@ -0,0 +1,26 @@
import {Component, inject} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogTitle} from "@angular/material/dialog";
import {Employee} from "../Employee";
import {MatButton} from "@angular/material/button";
import {DialogRef} from "@angular/cdk/dialog";
@Component({
selector: 'app-details',
imports: [
MatDialogTitle,
MatDialogContent,
MatButton,
MatDialogActions
],
templateUrl: './details.component.html',
standalone: true,
styleUrl: './details.component.css'
})
export class DetailsComponent {
employee: Employee = inject(MAT_DIALOG_DATA);
dialogRef: DialogRef = inject(DialogRef);
closeModal() {
this.dialogRef.close();
}
}

View File

View File

@ -0,0 +1,78 @@
<h2 mat-dialog-title>Edit Employee</h2>
<mat-dialog-content>
<form *ngIf="employeeForm" [formGroup]="employeeForm" (ngSubmit)="submit()">
<div class="!space-y-4">
<div class="flex gap-x-4">
<mat-form-field class="!w-full">
<mat-label>First Name</mat-label>
<input matInput formControlName="firstName" required>
<mat-hint>Enter the first name</mat-hint>
<mat-error *ngIf="errors['firstName']">{{errors['firstName']}}</mat-error>
</mat-form-field>
<mat-form-field class="!w-full">
<mat-label>Last Name</mat-label>
<input matInput formControlName="lastName" required>
<mat-hint>Enter the last name</mat-hint>
<mat-error *ngIf="errors['lastName']">{{errors['lastName']}}</mat-error>
</mat-form-field>
</div>
<mat-form-field class="!w-full">
<mat-label>Street</mat-label>
<input matInput formControlName="street" required>
<mat-hint>Enter the street address</mat-hint>
<mat-error *ngIf="errors['street']">{{errors['street']}}</mat-error>
</mat-form-field>
<div class="flex gap-x-4">
<mat-form-field class="!w-full">
<mat-label>City</mat-label>
<input matInput formControlName="city" required>
<mat-hint>Enter the city</mat-hint>
<mat-error *ngIf="errors['city']">{{errors['city']}}</mat-error>
</mat-form-field>
<mat-form-field class="!w-1/2">
<mat-label>Postcode</mat-label>
<input matInput formControlName="postcode" minlength="5" maxlength="5" type="number" required>
<mat-hint>Enter postcode</mat-hint>
<mat-error *ngIf="errors['postcode']">{{errors['postcode']}}</mat-error>
</mat-form-field>
</div>
<mat-form-field class="!w-full">
<mat-label>Phone</mat-label>
<input matInput formControlName="phone" required>
<mat-hint>Enter phone number</mat-hint>
<mat-error *ngIf="errors['phone']">{{errors['phone']}}</mat-error>
</mat-form-field>
<mat-form-field class="!w-full">
<mat-label>Qualifications</mat-label>
<mat-select formControlName="qualifications" multiple>
<mat-option *ngFor="let qualification of qualifications" [value]="qualification.id">
{{qualification.skill}}
</mat-option>
</mat-select>
<mat-hint>Select qualifications</mat-hint>
<mat-error *ngIf="errors['qualifications']">{{errors['qualifications']}}</mat-error>
</mat-form-field>
<mat-dialog-actions align="end" class="!px-0 !mb-0 flex flex-col sm:flex-row w-full gap-3">
<button mat-button
mat-dialog-close
class="text-sm md:text-base hover:bg-gray-100 py-2 px-6 rounded-md w-full sm:flex-1">
Cancel
</button>
<button mat-flat-button
color="primary"
type="submit"
class="!ml-0 text-sm md:text-base py-2 px-6 rounded-md w-full sm:flex-1">
Submit
</button>
</mat-dialog-actions>
</div>
</form>
</mat-dialog-content>

View File

@ -0,0 +1,126 @@
import {Component, inject, OnInit} from '@angular/core';
import {AbstractControl, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import {NgForOf, NgIf} from "@angular/common";
import {MatFormField, MatHint} from "@angular/material/form-field";
import {MatError, MatInput, MatLabel} from "@angular/material/input";
import {MatButton} from "@angular/material/button";
import {Employee} from "../Employee";
import EmployeeApiService from "../../services/employee-api.service";
import QualificationService from "../../services/qualification.service";
import {Qualification} from "../../qualification/Qualification";
import {MatOption, MatSelect} from "@angular/material/select";
import {debounceTime} from "rxjs";
@Component({
selector: 'app-edit',
imports: [
MatDialogTitle,
MatDialogContent,
NgIf,
ReactiveFormsModule,
MatFormField,
MatInput,
MatDialogActions,
MatButton,
MatDialogClose,
MatLabel,
MatSelect,
MatOption,
NgForOf,
MatHint,
MatError,
],
templateUrl: './edit.component.html',
standalone: true,
styleUrl: './edit.component.css'
})
export class EditComponent implements OnInit {
employeeForm!: FormGroup;
formBuilder: FormBuilder = inject(FormBuilder);
employeeService: EmployeeApiService = inject(EmployeeApiService);
qualificationService: QualificationService = inject(QualificationService);
dialogRef: MatDialogRef<EditComponent> = inject(MatDialogRef);
employee: Employee = inject(MAT_DIALOG_DATA);
qualifications: Qualification[] = [];
errorMsgs: { [key: string]: string } = {
firstName: 'First name is required',
lastName: 'Last name is required',
street: 'Street is required',
postcode: 'Postcode must be 5 characters long',
city: 'City is required',
phone: 'Phone is required',
qualifications: 'Qualifications are required'
}
errors: { [key: string]: string } = {}
ngOnInit(): void {
this.loadQualifications();
this.employeeForm = this.formBuilder.group({
firstName: [this.employee.firstName, Validators.required],
lastName: [this.employee.lastName, Validators.required],
street: [this.employee.street, Validators.required],
postcode: [this.employee.postcode, [Validators.required, this.validatePostcode]],
city: [this.employee.city, Validators.required],
phone: [this.employee.phone, Validators.required],
qualifications: [this.employee.skillSet?.map(skill => skill.id) ?? []]
});
Object.keys(this.employeeForm.controls).forEach((controlName: string) => {
const control = this.employeeForm.controls[controlName];
control.valueChanges.pipe(debounceTime(10)).subscribe(() => {
this.showErrorMsg(controlName, control);
});
});
}
loadQualifications() {
this.qualificationService.getAll().subscribe(
qualifications => this.qualifications = qualifications
);
}
submit() {
if (this.employeeForm === null || !this.employeeForm.valid) {
console.error('Form invalid');
return;
}
if (this.employee.id === undefined) {
console.error('Employee ID is undefined');
return;
}
const formValue = this.employeeForm.value;
const employeeData = {
...formValue,
skillSet: formValue.qualifications
};
this.employeeService.update(employeeData as Employee, this.employee.id).subscribe();
this.dialogRef.close(true);
}
showErrorMsg(controlName: string, control: AbstractControl | undefined) {
if (control?.errors) {
this.errors[controlName] = this.errorMsgs[controlName];
}
}
validatePostcode(control: AbstractControl) {
const postcode = control.value as number;
if (postcode.toString().length !== 5) {
return {invalidPostcode: true};
}
return null;
}
}

View File

@ -0,0 +1,73 @@
:host ::ng-deep {
.mat-mdc-card {
--mdc-elevated-card-container-color: transparent;
@apply !shadow-none !rounded-xl;
}
.mat-mdc-button-base {
--mat-mdc-button-persistent-ripple-color: currentColor;
@apply !rounded-lg;
}
.mat-mdc-progress-spinner {
--mdc-circular-progress-active-indicator-color: #2563eb;
}
.mdc-data-table__header-cell {
@apply !text-gray-600 !font-semibold !text-sm !py-4 !px-6;
}
.mat-mdc-table {
@apply !bg-transparent !border-separate !border-spacing-y-2;
.mat-mdc-row {
@apply !bg-white !rounded-xl !shadow-sm !transition-all !duration-200;
&:hover {
@apply !bg-gray-50 !shadow-md !transform !scale-[1.01];
}
.mat-mdc-cell {
@apply !border-b-0 !py-4 !px-6 first:!rounded-l-xl last:!rounded-r-xl;
}
}
.mat-mdc-header-row {
@apply !bg-transparent;
.mat-mdc-header-cell {
@apply !border-b-0;
}
}
}
.mat-mdc-menu-panel {
@apply !rounded-xl !shadow-lg;
}
.mat-mdc-menu-item {
@apply !rounded-lg !mx-1 !my-0.5;
}
.mdc-button {
@apply !font-medium;
&.mat-primary {
@apply !bg-blue-600 !text-white hover:!bg-blue-700;
}
&.mat-warn {
@apply !bg-red-600 !text-white hover:!bg-red-700;
}
}
.mat-mdc-snack-bar-container {
&.error-snackbar {
@apply !rounded-xl;
.mdc-snackbar__surface {
@apply !bg-red-50 !text-red-900 !border !border-red-100;
}
}
}
}

View File

@ -0,0 +1,105 @@
<section class="!space-y-6 mb-6">
@defer {
@if (employees$ | async; as employees) {
<div class="!space-y-6">
<div class="!flex !justify-between !items-center">
<h2 class="!text-2xl !font-semibold !text-gray-900">Employee Directory</h2>
<button mat-flat-button color="primary" class="!bg-blue-600 !text-white" (click)="showCreateEmployeeModal()">
<mat-icon class="!mr-2">add</mat-icon>
Add Employee
</button>
</div>
@if (employees) {
<div class="!overflow-x-auto !rounded-lg !bg-gray-50 !p-4">
<table mat-table [dataSource]="employees" matSort class="!w-full">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef class="!text-left !w-full"> Name</th>
<td mat-cell *matCellDef="let employee" class="!py-4">
<div class="!flex !items-center">
<div class="!h-10 !w-10 !rounded-full !bg-blue-100 !flex !items-center !justify-center !mr-3">
<span class="!text-blue-600 !font-medium">
{{ employee.firstName[0] }}{{ employee.lastName[0] }}
</span>
</div>
<div>
<a
class="!text-blue-600 hover:!underline cursor-pointer"
[matTooltip]="'Click to view Employee details'"
(click)="openDetailModal(employee)">
{{ employee.lastName }}, {{ employee.firstName }}
</a>
</div>
</div>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="!text-right !w-[120px]"> Actions</th>
<td mat-cell *matCellDef="let employee" class="!text-right !py-4 !whitespace-nowrap">
<div class="!flex !justify-end !items-center !gap-1">
<button mat-icon-button
color="primary"
[matTooltip]="'Edit employee'"
(click)="showEditEmployeeModal(employee)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button
color="warn"
[matTooltip]="'Delete employee'"
(click)="openDeleteDialogue(employee)">
<mat-icon>delete</mat-icon>
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
} @else {
<mat-card class="!text-center !py-8">
<mat-card-content>
<mat-icon class="!w-8 !h-8 !text-gray-400 !mb-4">people</mat-icon>
<p class="!text-gray-600">No employees found</p>
</mat-card-content>
</mat-card>
}
</div>
}
} @placeholder {
<div class="!space-y-6">
<div class="!animate-pulse">
<div class="!flex !justify-between !items-center !mb-8">
<div class="!h-8 !bg-gray-200 !rounded !w-1/4"></div>
<div class="!h-10 !bg-gray-200 !rounded !w-32"></div>
</div>
<div class="!bg-gray-50 !p-4 !rounded-lg">
<div class="!space-y-4">
<div class="!h-10 !bg-gray-200 !rounded"></div>
@for (i of [1, 2, 3]; track i) {
<div class="!h-16 !bg-white !rounded-lg !shadow-sm"></div>
}
</div>
</div>
</div>
</div>
} @error {
<mat-card class="!bg-red-50 !border !border-red-100">
<mat-card-content class="!p-4">
<div class="!flex !items-center !gap-4 !text-red-800">
<mat-icon class="!text-red-500 !w-8 !h-8">error</mat-icon>
<div>
<h3 class="!font-medium !mb-1">Error loading employees</h3>
<p class="!text-sm !text-red-700">Please try refreshing the page.</p>
</div>
</div>
</mat-card-content>
</mat-card>
} @loading {
<div class="!flex !justify-center !items-center !py-12">
<mat-spinner diameter="48" class="!text-blue-600"></mat-spinner>
</div>
}
</section>

View File

@ -0,0 +1,109 @@
import {Component, inject, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {catchError, Observable, of, retry} from 'rxjs';
import {HttpErrorResponse} from '@angular/common/http';
import {Employee} from '../Employee';
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 {MatSnackBar, MatSnackBarModule} from '@angular/material/snack-bar';
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 {MatDialog} from "@angular/material/dialog";
import {DeleteComponent} from "../delete/delete.component";
import EmployeeApiService from "../../services/employee-api.service";
import {CreateComponent} from "../create/create.component";
import {EditComponent} from "../edit/edit.component";
import {DetailsComponent} from "../details/details.component";
@Component({
selector: 'app-employee-list',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatSnackBarModule,
MatDividerModule,
MatTooltipModule,
MatMenuModule,
MatTableModule,
MatSortModule
],
templateUrl: './table.component.html',
styleUrl: './table.component.css'
})
export class TableComponent implements OnInit {
private readonly apiService: EmployeeApiService = inject(EmployeeApiService);
private readonly snackBar: MatSnackBar = inject(MatSnackBar);
private readonly matDialog: MatDialog = inject(MatDialog);
private static readonly MAX_RETRIES = 3;
public employees$: Observable<Employee[]> = of([]);
public readonly displayedColumns: string[] = ['name', 'actions'];
public ngOnInit(): void {
this.employees$ = this.fetchEmployees();
}
private fetchEmployees(): Observable<Employee[]> {
return this.apiService.getAll().pipe(
retry(TableComponent.MAX_RETRIES),
catchError((error: HttpErrorResponse) => {
console.error('Error fetching employees:', error);
this.showErrorMessage('Failed to load employees. Please try again.');
return of([]);
})
);
}
private showErrorMessage(message: string): void {
this.snackBar.open(message, 'Close', {
duration: 5000,
horizontalPosition: 'end',
verticalPosition: 'bottom',
panelClass: ['!bg-red-50', '!text-red-900', '!border', '!border-red-100']
});
}
protected openDeleteDialogue(employee: Employee): void {
this.matDialog.open(DeleteComponent, {data: employee})
.afterClosed()
.subscribe((deleted: boolean) => {
if (deleted) {
this.employees$ = this.fetchEmployees();
}
});
}
protected showCreateEmployeeModal() {
this.matDialog.open(CreateComponent)
.afterClosed()
.subscribe((created: boolean) => {
if (created) {
this.employees$ = this.fetchEmployees();
}
});
}
protected showEditEmployeeModal(employee: Employee) {
this.matDialog.open(EditComponent, {data: employee})
.afterClosed()
.subscribe((edited: boolean) => {
if (edited) {
this.employees$ = this.fetchEmployees();
}
});
}
protected openDetailModal(employee: Employee) {
this.matDialog.open(DetailsComponent, {data: employee});
}
}

View File

View File

@ -0,0 +1,2 @@
<app-employee-list></app-employee-list>
<app-qualifications></app-qualifications>

View File

@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import {TableComponent} from "../employee/table/table.component";
import {QualificationsComponent} from "../qualification/table/table.component";
@Component({
selector: 'app-home',
imports: [
TableComponent,
QualificationsComponent
],
templateUrl: './home.component.html',
styleUrl: './home.component.css'
})
export class HomeComponent {
}

View File

@ -0,0 +1,6 @@
.dot-loader {
font-size: 1.5rem;
font-weight: bold;
color: #555;
text-align: center;
}

View File

@ -0,0 +1 @@
<div class="dot-loader">Logging in<span>{{ dots }}</span></div>

View File

@ -0,0 +1,27 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {interval, Subscription} from "rxjs";
@Component({
selector: 'app-login',
imports: [],
templateUrl: './login.component.html',
standalone: true,
styleUrl: './login.component.css'
})
export class LoginComponent implements OnInit, OnDestroy{
dots: string = '';
private maxDots: number = 4; // Maximum number of dots
private intervalSub!: Subscription;
ngOnInit(): void {
this.intervalSub = interval(500).subscribe(() => {
this.dots = this.dots.length < this.maxDots ? this.dots + '.' : '';
});
}
ngOnDestroy(): void {
if (this.intervalSub) {
this.intervalSub.unsubscribe();
}
}
}

View File

@ -0,0 +1,4 @@
export class Qualification {
constructor(public id: number, public skill?: string) {
}
}

View File

@ -0,0 +1,44 @@
<h2 mat-dialog-title class="text-xl md:text-2xl font-semibold text-gray-800 mb-3 md:mb-4">Create Qualification</h2>
<mat-dialog-content class="!px-3 md:!px-6">
<form [formGroup]="qualificationForm" (ngSubmit)="create()" class="w-full min-w-[280px] md:min-w-[400px]">
<div class="space-y-4 md:space-y-6">
@if (apiErrorMessage) {
<div class="bg-red-50 p-3 md:p-4 rounded-lg">
<div class="flex items-start space-x-2 md:space-x-3">
<mat-icon class="text-red-600 text-xl md:text-2xl !w-8 !h-8">error</mat-icon>
<mat-error class="text-sm md:text-base text-red-700">{{ apiErrorMessage }}</mat-error>
</div>
</div>
}
<div class="bg-gray-50 p-3 md:p-4 rounded-lg space-y-3">
<mat-form-field class="w-full">
<mat-label>Skill</mat-label>
<input matInput
formControlName="skill"
placeholder="Enter skill name"
required>
<mat-hint class="text-sm">Enter the skill name</mat-hint>
<mat-error *ngIf="isFieldInvalid('skill')" class="text-sm">
{{ getErrorMessage('skill') }}
</mat-error>
</mat-form-field>
</div>
<mat-dialog-actions align="end" class="!px-0 !mb-0 flex flex-col sm:flex-row w-full gap-3">
<button mat-button
mat-dialog-close
class="text-sm md:text-base hover:bg-gray-100 py-2 px-4 md:px-6 rounded-md w-full sm:flex-1">
Cancel
</button>
<button mat-flat-button
color="primary"
type="submit"
class="!ml-0 text-sm md:text-base py-2 px-4 md:px-6 rounded-md w-full sm:flex-1">
Create
</button>
</mat-dialog-actions>
</div>
</form>
</mat-dialog-content>

View File

@ -0,0 +1,86 @@
import {Component, inject} from '@angular/core';
import {FormBuilder, ReactiveFormsModule, Validators} from "@angular/forms";
import QualificationService from "../../services/qualification.service";
import {
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import {NgIf} from "@angular/common";
import {MatError, MatFormField, MatHint, MatLabel} from "@angular/material/form-field";
import {MatButton} from "@angular/material/button";
import {MatInput} from "@angular/material/input";
import {MatIcon} from "@angular/material/icon";
import {filter} from "rxjs";
@Component({
selector: 'app-create-qualification',
imports: [
ReactiveFormsModule,
MatError,
NgIf,
MatLabel,
MatDialogTitle,
MatDialogContent,
MatFormField,
MatDialogActions,
MatButton,
MatInput,
MatDialogClose,
MatHint,
MatIcon
],
templateUrl: './create.component.html',
styleUrl: './create.component.css'
})
export class CreateComponent {
private formBuilder: FormBuilder = inject(FormBuilder);
private qualificationService: QualificationService = inject(QualificationService);
private dialogRef: MatDialogRef<CreateComponent> = inject(MatDialogRef);
public apiErrorMessage: string = '';
qualificationForm = this.formBuilder.group({
'skill': ['', Validators.required],
});
isFieldInvalid(fieldName: string): boolean {
const field = this.qualificationForm.get(fieldName);
if (!field) {
throw new Error('Form field does not exist: ' + fieldName)
}
return field.invalid && (field.dirty || field.touched);
}
getErrorMessage(fieldName: string): string {
const field = this.qualificationForm.get(fieldName);
if (field?.errors?.['required']) {
return 'This field is required';
}
return '';
}
create() {
if (!this.qualificationForm.valid) {
console.error('Validation failed');
return;
}
this.qualificationService.create(this.qualificationForm.value).subscribe({
next: (createdQualification) => {
this.dialogRef.close(createdQualification);
},
error: (error) => {
console.error('Error creating qualification:', error);
this.apiErrorMessage = 'API Error';
}
});
}
}

View File

@ -0,0 +1,43 @@
<h2 mat-dialog-title class="text-xl md:text-2xl font-semibold text-gray-800 mb-3 md:mb-4">Delete Qualification</h2>
<mat-dialog-content class="!px-3 md:!px-6">
<div class="w-full min-w-[280px] md:min-w-[400px] space-y-4 md:space-y-6">
@if (apiError) {
<div class="bg-red-50 p-3 md:p-4 rounded-lg border border-red-200">
<div class="flex items-start space-x-2 md:space-x-3">
<mat-icon class="text-red-600 text-xl md:text-2xl !w-8 !h-8">error</mat-icon>
<div>
<p class="text-gray-800 font-medium text-sm md:text-base">There was an error deleting the qualification.</p>
<p class="text-gray-600 mt-1 text-xs md:text-sm">{{ apiError }}</p>
</div>
</div>
</div>
}
<div class="bg-amber-50 p-3 md:p-4 rounded-lg border border-amber-200">
<div class="flex items-start space-x-2 md:space-x-3">
<mat-icon class="text-amber-600 text-xl md:text-2xl !w-8 !h-8">warning</mat-icon>
<div>
<p class="text-gray-800 font-medium text-sm md:text-base">Are you sure you want to delete this qualification?</p>
<p class="text-gray-600 mt-1 text-xs md:text-sm">This action cannot be undone.</p>
</div>
</div>
</div>
<mat-dialog-actions align="end" class="!px-0 !mb-0 flex flex-col sm:flex-row w-full gap-3">
<button mat-button
(click)="closeModal()"
class="text-sm md:text-base hover:bg-gray-100 py-2 px-4 md:px-6 rounded-md w-full sm:flex-1">
Cancel
</button>
<button mat-flat-button
color="warn"
(click)="delete()"
class="!ml-0 text-sm md:text-base py-2 px-4 md:px-6 rounded-md w-full sm:flex-1"
cdkFocusInitial>
Delete
</button>
</mat-dialog-actions>
</div>
</mat-dialog-content>

View File

@ -0,0 +1,60 @@
import {Component, inject} from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import QualificationService from "../../services/qualification.service";
import {MatButton} from "@angular/material/button";
import {HttpErrorResponse} from "@angular/common/http";
import { MatError } from '@angular/material/form-field'
import {MatIcon} from "@angular/material/icon";
@Component({
selector: 'app-delete-qualification',
imports: [
FormsModule,
MatDialogContent,
MatDialogTitle,
ReactiveFormsModule,
MatDialogActions,
MatButton,
MatError,
MatIcon
],
templateUrl: './delete.component.html',
standalone: true,
styleUrl: './delete.component.css'
})
export class DeleteComponent {
public id: number = inject(MAT_DIALOG_DATA);
public apiError: string | null = null;
private qualificationService: QualificationService = inject(QualificationService);
private dialogRef: MatDialogRef<DeleteComponent> = inject(MatDialogRef);
delete() {
this.qualificationService.delete(this.id).subscribe({
next: () => {
this.dialogRef.close(true);
},
error: (error: HttpErrorResponse) => {
console.error('Error deleting qualification:', error);
if (error.error.message.includes('SQL')) {
// The API message is undescriptive but this is the most common
this.apiError = 'This qualification cannot be deleted because it is currently assigned to one or more employees';
} else {
this.apiError = 'API Error';
}
}
});
}
closeModal() {
this.dialogRef.close(false);
}
}

View File

@ -0,0 +1,42 @@
<h2 mat-dialog-title class="text-xl md:text-2xl font-semibold text-gray-800 mb-3 md:mb-4">
{{ qualification.skill }} Developers
</h2>
<mat-dialog-content class="!px-3 md:!px-6">
<div class="w-full min-w-[280px] md:min-w-[400px] space-y-4 md:space-y-6">
@if (employees$ | async; as employees) {
@if (employees.length === 0) {
<div class="bg-gray-50 p-3 md:p-4 rounded-lg text-center">
<mat-icon class="text-gray-400 text-xl md:text-2xl mb-2 !w-8 !h-8">person_off</mat-icon>
<p class="text-gray-600 text-sm md:text-base">No employees found with this qualification.</p>
</div>
} @else {
<div class="bg-gray-50 p-3 md:p-4 rounded-lg space-y-2">
@for (employee of employees; track employee.id) {
<a class="block w-full p-3 bg-white rounded-lg hover:bg-blue-50 transition-colors cursor-pointer border border-gray-100 hover:border-blue-100"
(click)="openEmployeeDetailsModal(employee.id)">
<div class="flex items-center space-x-3">
<div class="h-8 w-8 md:h-10 md:w-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<span class="text-blue-600 font-medium text-sm md:text-base">
{{ employee.firstName?.charAt(0) }}{{ employee.lastName?.charAt(0) }}
</span>
</div>
<div>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ employee.firstName }} {{ employee.lastName }}</span>
</div>
</div>
</a>
}
</div>
}
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" class="!px-3 md:!px-6 !py-4 !mt-4 border-t">
<button mat-button
(click)="closeModal()"
class="text-sm md:text-base hover:bg-gray-100 py-2 px-4 md:px-6 rounded-md w-full">
Close
</button>
</mat-dialog-actions>

View File

@ -0,0 +1,55 @@
import {Component, inject} from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import QualificationService from "../../services/qualification.service";
import {Qualification} from "../Qualification";
import {AsyncPipe} from "@angular/common";
import {MatButton} from "@angular/material/button";
import {DetailsComponent as EmployeeDetailsComponent} from "../../employee/details/details.component";
import EmployeeApiService from "../../services/employee-api.service";
import {MatIcon} from "@angular/material/icon";
@Component({
selector: 'app-details',
imports: [
AsyncPipe,
MatDialogContent,
MatDialogTitle,
MatDialogActions,
MatButton,
MatIcon
],
templateUrl: './details.component.html',
styleUrl: './details.component.css'
})
export class DetailsComponent {
private qualificationService = inject(QualificationService);
private employeeService = inject(EmployeeApiService);
private dialogRef: MatDialogRef<DetailsComponent> = inject(MatDialogRef);
private dialog: MatDialog = inject(MatDialog);
public qualification: Qualification = inject(MAT_DIALOG_DATA);
public employees$ = this.qualificationService.findEmployees(this.qualification.id);
closeModal() {
this.dialogRef.close();
}
openEmployeeDetailsModal(id: number | undefined) {
if (!id) {
throw new Error("ID must not be undefined");
}
this.employeeService.getById(id).subscribe(employee => {
this.dialog.open(EmployeeDetailsComponent, {
data: employee
});
});
}
}

View File

@ -0,0 +1,44 @@
<h2 mat-dialog-title class="text-xl md:text-2xl font-semibold text-gray-800 mb-3 md:mb-4">Edit Qualification</h2>
<mat-dialog-content class="!px-3 md:!px-6">
<form [formGroup]="qualificationForm" (ngSubmit)="edit()" class="w-full min-w-[280px] md:min-w-[400px]">
<div class="space-y-4 md:space-y-6">
@if (apiErrorMessage) {
<div class="bg-red-50 p-3 md:p-4 rounded-lg">
<div class="flex items-start space-x-2 md:space-x-3">
<mat-icon class="text-red-600 text-xl md:text-2xl !w-8 !h-8">error</mat-icon>
<mat-error class="text-sm md:text-base text-red-700">{{ apiErrorMessage }}</mat-error>
</div>
</div>
}
<div class="bg-gray-50 p-3 md:p-4 rounded-lg space-y-3">
<mat-form-field class="w-full">
<mat-label>Skill</mat-label>
<input matInput
formControlName="skill"
placeholder="Enter skill name"
required>
<mat-hint class="text-sm">Enter the skill name</mat-hint>
<mat-error *ngIf="isFieldInvalid('skill')" class="text-sm">
{{ getErrorMessage('skill') }}
</mat-error>
</mat-form-field>
</div>
<mat-dialog-actions align="end" class="!px-0 !mb-0 flex flex-col sm:flex-row w-full gap-3">
<button mat-button
mat-dialog-close
class="text-sm md:text-base hover:bg-gray-100 py-2 px-4 md:px-6 rounded-md w-full sm:flex-1">
Cancel
</button>
<button mat-flat-button
color="primary"
type="submit"
class="!ml-0 text-sm md:text-base py-2 px-4 md:px-6 rounded-md w-full sm:flex-1">
Save Changes
</button>
</mat-dialog-actions>
</div>
</form>
</mat-dialog-content>

View File

@ -0,0 +1,88 @@
import {Component, inject} from '@angular/core';
import {FormBuilder, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
import QualificationService from "../../services/qualification.service";
import {
MAT_DIALOG_DATA,
MatDialogActions, MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import {MatButton} from "@angular/material/button";
import {MatError, MatFormField, MatHint, MatLabel} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {NgIf} from "@angular/common";
import {Qualification} from "../Qualification";
import {MatIcon} from "@angular/material/icon";
@Component({
selector: 'app-edit-qualification',
imports: [
FormsModule,
MatButton,
MatDialogActions,
MatDialogContent,
MatDialogTitle,
MatError,
MatFormField,
MatInput,
MatLabel,
NgIf,
ReactiveFormsModule,
MatDialogClose,
MatHint,
MatIcon
],
templateUrl: './edit.component.html',
styleUrl: './edit.component.css'
})
export class EditComponent {
public apiErrorMessage: string = '';
public qualification: Qualification = inject(MAT_DIALOG_DATA);
private formBuilder: FormBuilder = inject(FormBuilder);
private qualificationService: QualificationService = inject(QualificationService);
private dialogRef: MatDialogRef<EditComponent> = inject(MatDialogRef);
qualificationForm = this.formBuilder.group({
'skill': [this.qualification.skill, Validators.required],
});
isFieldInvalid(fieldName: string): boolean {
const field = this.qualificationForm.get(fieldName);
if (!field) {
throw new Error('Form field does not exist: ' + fieldName)
}
return field.invalid && (field.dirty || field.touched);
}
getErrorMessage(fieldName: string): string {
const field = this.qualificationForm.get(fieldName);
if (field?.errors?.['required']) {
return 'This field is required';
}
return '';
}
edit() {
if (!this.qualificationForm.valid) {
console.error('Validation failed');
return;
}
this.qualificationService.edit(this.qualification.id, this.qualificationForm.value).subscribe({
next: (editedQualification) => {
this.dialogRef.close(editedQualification);
},
error: (error) => {
console.error('Error creating qualification:', error);
this.apiErrorMessage = 'API Error';
}
});
}
}

View File

@ -0,0 +1,105 @@
<section class="!space-y-6 mb-6">
@defer {
@if (qualifications$ | async; as qualifications) {
<div class="!space-y-6">
<div class="!flex !justify-between !items-center">
<h2 class="!text-2xl !font-semibold !text-gray-900">Qualifications</h2>
<button mat-flat-button color="primary" class="!bg-blue-600 !text-white"
(click)="openCreateModal()">
<mat-icon class="!mr-2">add</mat-icon>
Add Qualification
</button>
</div>
@if (qualifications) {
<div class="!overflow-x-auto !rounded-lg !bg-gray-50 !p-4">
<table mat-table [dataSource]="qualifications" matSort class="!w-full">
<ng-container matColumnDef="skill">
<th mat-header-cell *matHeaderCellDef class="!text-left !w-full">Skill</th>
<td mat-cell *matCellDef="let qualification" class="!py-4">
<div class="!flex !items-center">
<div class="!h-10 !w-10 !rounded-full !bg-blue-100 !flex !items-center !justify-center !mr-3">
<span class="!text-blue-600 !font-medium">
{{ qualification.skill[0]?.toUpperCase() }}
</span>
</div>
<div>
<a class="!text-blue-600 hover:!underline cursor-pointer"
[matTooltip]="'Click to view qualification details'"
(click)="openDetailsModal(qualification)">
{{ qualification.skill }}
</a>
</div>
</div>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="!text-right !w-[120px]">Actions</th>
<td mat-cell *matCellDef="let qualification" class="!text-right !py-4 !whitespace-nowrap">
<div class="!flex !justify-end !items-center !gap-1">
<button mat-icon-button
color="primary"
[matTooltip]="'Edit qualification'"
(click)="openEditModal(qualification)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button
color="warn"
[matTooltip]="'Delete qualification'"
(click)="openDeleteModal(qualification.id)">
<mat-icon>delete</mat-icon>
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
} @else {
<mat-card class="!text-center !py-8">
<mat-card-content>
<mat-icon class="!w-8 !h-8 !text-gray-400 !mb-4">school</mat-icon>
<p class="!text-gray-600">No qualifications found</p>
</mat-card-content>
</mat-card>
}
</div>
}
} @placeholder {
<div class="!space-y-6">
<div class="!animate-pulse">
<div class="!flex !justify-between !items-center !mb-8">
<div class="!h-8 !bg-gray-200 !rounded !w-1/4"></div>
<div class="!h-10 !bg-gray-200 !rounded !w-32"></div>
</div>
<div class="!bg-gray-50 !p-4 !rounded-lg">
<div class="!space-y-4">
<div class="!h-10 !bg-gray-200 !rounded"></div>
@for (i of [1, 2, 3]; track i) {
<div class="!h-16 !bg-white !rounded-lg !shadow-sm"></div>
}
</div>
</div>
</div>
</div>
} @error {
<mat-card class="!bg-red-50 !border !border-red-100">
<mat-card-content class="!p-4">
<div class="!flex !items-center !gap-4 !text-red-800">
<mat-icon class="!text-red-500 !w-8 !h-8">error</mat-icon>
<div>
<h3 class="!font-medium !mb-1">Error loading qualifications</h3>
<p class="!text-sm !text-red-700">Please try refreshing the page.</p>
</div>
</div>
</mat-card-content>
</mat-card>
} @loading {
<div class="!flex !justify-center !items-center !py-12">
<mat-spinner diameter="48" class="!text-blue-600"></mat-spinner>
</div>
}
</section>

View File

@ -0,0 +1,107 @@
import {Component, inject, OnInit} from '@angular/core';
import {Observable} from "rxjs";
import {Qualification} from "../Qualification";
import {MatDialog} from "@angular/material/dialog";
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 {AsyncPipe} from "@angular/common";
import {MatButton, MatIconButton} from "@angular/material/button";
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
MatTable
} from "@angular/material/table";
import {MatIcon} from "@angular/material/icon";
import {MatCard, MatCardContent} from "@angular/material/card";
import {MatTooltip} from "@angular/material/tooltip";
import {MatProgressSpinner} from "@angular/material/progress-spinner";
import {DetailsComponent} from "../details/details.component";
import {MatSort} from "@angular/material/sort";
@Component({
selector: 'app-qualifications',
imports: [
AsyncPipe,
MatButton,
MatTable,
MatColumnDef,
MatHeaderCell,
MatCell,
MatHeaderCellDef,
MatCellDef,
MatIconButton,
MatIcon,
MatHeaderRow,
MatRow,
MatHeaderRowDef,
MatRowDef,
MatCard,
MatCardContent,
MatTooltip,
MatProgressSpinner,
MatSort
],
templateUrl: './table.component.html',
styleUrl: './table.component.css'
})
export class QualificationsComponent implements OnInit {
public qualifications$!: Observable<Qualification[]>;
public readonly displayedColumns: string[] = ['skill', 'actions'];
private readonly dialog: MatDialog = inject(MatDialog);
private readonly qualificationService: QualificationService = inject(QualificationService);
ngOnInit() {
this.loadQualifications();
}
private loadQualifications() {
this.qualifications$ = this.qualificationService.getAll();
}
openCreateModal() {
const dialogRef = this.dialog.open(CreateComponent);
dialogRef.afterClosed().subscribe((success: boolean) => {
if (success) {
this.loadQualifications();
}
});
}
openEditModal(qualification: Qualification) {
const dialogRef = this.dialog.open(EditComponent, {
data: qualification
});
dialogRef.afterClosed().subscribe((success: boolean) => {
if (success) {
this.loadQualifications();
}
});
}
openDeleteModal(id: number) {
const dialogRef = this.dialog.open(DeleteComponent, {
data: id
});
dialogRef.afterClosed().subscribe((success: boolean) => {
if (success) {
this.loadQualifications();
}
});
}
openDetailsModal(qualification: Qualification) {
const dialogRef = this.dialog.open(DetailsComponent, {
data: qualification
});
}
}

View File

@ -0,0 +1,22 @@
import {Injectable} from '@angular/core';
import {AuthService} from "./auth.service";
import {Router} from "@angular/router";
import {KeycloakService} from "keycloak-angular";
@Injectable({
providedIn: 'root'
})
export class AuthGuardService {
constructor(public auth: AuthService, public router: Router) {
}
canActivate(): boolean {
if (!this.auth.isAuthenticated()) {
this.router.navigate(['login']);
return false;
}
return true;
}
}

View File

@ -0,0 +1,19 @@
import {inject, Injectable} from '@angular/core';
import {KeycloakService} from "keycloak-angular";
@Injectable({
providedIn: 'root'
})
export class AuthService {
private keycloakService = inject(KeycloakService);
public isAuthenticated(): boolean {
if (this.keycloakService.isLoggedIn()) {
return true;
}
this.keycloakService.login();
return false;
}
}

View File

@ -0,0 +1,34 @@
import {inject, Injectable} from "@angular/core";
import {HttpClient} from "@angular/common/http";
import {Observable} from "rxjs";
import {Employee} from "../employee/Employee";
@Injectable({
providedIn: 'root'
})
export default class EmployeeApiService {
private http: HttpClient = inject(HttpClient);
private static readonly BASE_URL = 'http://localhost:8089';
public getById(id: number): Observable<Employee> {
return this.http.get(`${EmployeeApiService.BASE_URL}/employees/${id}`)
}
public deleteById(id: number): Observable<Employee> {
return this.http.delete(`${EmployeeApiService.BASE_URL}/employees/${id}`)
}
public getAll(): Observable<Employee[]> {
return this.http.get<Employee[]>(`${EmployeeApiService.BASE_URL}/employees`)
}
public create(employee: Employee) {
return this.http.post<Employee>(`${EmployeeApiService.BASE_URL}/employees`, employee)
}
public update(employee: Employee, id: number) {
return this.http.put(`${EmployeeApiService.BASE_URL}/employees/${id}`, employee)
}
}

View File

@ -0,0 +1,40 @@
import {inject, Injectable} from "@angular/core";
import {HttpClient} from "@angular/common/http";
import {map, Observable} from "rxjs";
import {Qualification} from "../qualification/Qualification";
import {Employee} from "../employee/Employee";
@Injectable({
providedIn: 'root'
})
export default class QualificationService {
private http: HttpClient = inject(HttpClient);
private static readonly BASE_URL = 'http://localhost:8089';
public getAll(): Observable<Qualification[]> {
return this.http.get<Qualification[]>(`${QualificationService.BASE_URL}/qualifications`).pipe(
map(qualifications => qualifications.sort((a, b) => a.id - b.id))
)
}
public create(data: any) {
return this.http.post(`${QualificationService.BASE_URL}/qualifications`, data)
}
public edit(id: number, data: any) {
return this.http.put(`${QualificationService.BASE_URL}/qualifications/${id}`, data)
}
public delete(id: number) {
return this.http.delete(`${QualificationService.BASE_URL}/qualifications/${id}`)
}
public findEmployees(id: number): Observable<Employee[]> {
return this.http.get<any>(`${QualificationService.BASE_URL}/qualifications/${id}/employees`)
.pipe(
map(response => response.employees)
);
}
}

View File

@ -9,7 +9,7 @@
<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">
<body class="mat-typography bg-white">
<app-root></app-root>
</body>
</html>

View File

@ -1,4 +1,6 @@
/* You can add global styles to this file, and also import other style files */
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }

11
tailwind.config.js Normal file
View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{html,ts,css,scss,sass,less,styl}',
],
theme: {
extend: {},
},
plugins: [],
}