43 Commits

Author SHA1 Message Date
ca893507c6 feat: add logout button and update title in header 2025-01-23 12:59:59 +01:00
64d8ce5837 add no data found messages (#49)
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/49
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Co-committed-by: Jan-Marlon Leibl <jleibl@proton.me>
2025-01-23 11:46:45 +00:00
eac3d9c834 remove unused css files and styleUrl properties from components (#48)
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/48
Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Co-committed-by: Jan-Marlon Leibl <jleibl@proton.me>
2025-01-23 11:39:26 +00:00
0be39d98ec format code and improve readability across files (#47)
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/47
Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Co-committed-by: Jan-Marlon Leibl <jleibl@proton.me>
2025-01-23 11:24:38 +00:00
26ba6b1054 add ESLint configuration and update scripts (#46)
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/46
Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Co-committed-by: Jan-Marlon Leibl <jleibl@proton.me>
2025-01-23 10:59:15 +00:00
556ba08234 refactor api services (#44)
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/44
Reviewed-by: Huy <ptran@noreply@simonis.lol>
Co-authored-by: Constantin Simonis <constantin@simonis.lol>
Co-committed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-22 08:03:32 +00:00
Huy
d25fe3ff64 Extract snackbar error message implementation to service (#42)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/42
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-22 07:58:43 +00:00
44cf3de5a0 add search bar to employee list (#41)
Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/41
Co-authored-by: Constantin Simonis <constantin@simonis.lol>
Co-committed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-15 13:15:33 +00:00
c41eceb51d task/update-error-message-styling (#40)
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/40
Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Co-committed-by: Jan-Marlon Leibl <jleibl@proton.me>
2025-01-15 11:33:14 +00:00
3980408403 add errors to edit employee form (#38)
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/38
Reviewed-by: Hop In, I Have Puppies AND WiFi <jleibl@noreply@simonis.lol>
Co-authored-by: Constantin Simonis <constantin@simonis.lol>
Co-committed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-15 11:00:42 +00:00
95bf76f9c1 Enhance employee and qualification forms with hints and improved layouts (#37)
- Added hints to input fields in create and edit employee forms for better user guidance.
- Updated the layout of dialog actions in employee and qualification forms for improved usability.
- Enhanced delete confirmation dialogs for qualifications and employees with better styling and error handling.
- Improved the display of employee details and qualifications with better formatting and structure.

These changes aim to improve user experience and accessibility across the application.

Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/37
Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Co-committed-by: Jan-Marlon Leibl <jleibl@proton.me>
2025-01-15 10:34:10 +00:00
Huy
37b5c27a50 Add cancel button to create and edit qualification modals (#36)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/36
Co-authored-by: Huy <ptran@noreply@simonis.lol>
Co-committed-by: Huy <ptran@noreply@simonis.lol>
2025-01-15 09:05:48 +00:00
Huy
17912451d6 Display more accurate error message (#35)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/35
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-15 08:33:06 +00:00
Huy
d96bf3d475 Fix employee details opened from qualification details not showing any skills (#34)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/34
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-15 08:31:12 +00:00
8284e1634d add errors to employee creation form
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/33
Reviewed-by: Huy <ptran@noreply@simonis.lol>
2025-01-15 08:27:46 +00:00
Huy
ee1e86f4e7 Remove minimum loading time for both tables (#32)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/32
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-15 07:48:40 +00:00
Huy
e55b3471cd Add more descriptive error message to skill deletion (#31)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/31
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-15 07:44:23 +00:00
423116ed34 Implement removing and adding qualifications while creating or editing employees (#29)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #29
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-09 12:58:48 +00:00
7653a109ee fix bug where tables reload on action cancel (#30)
Reviewed-on: #30
Reviewed-by: Hop In, I Have Puppies AND WiFi <jleibl@noreply@simonis.lol>
Co-authored-by: Constantin Simonis <constantin@simonis.lol>
Co-committed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-09 12:54:02 +00:00
89bea09476 add animation to login
Reviewed-on: #28
Reviewed-by: Get in my car i have candy <huydw@proton.me>
2025-01-09 12:43:53 +00:00
f33082343c add employee details
Reviewed-on: #27
Reviewed-by: Get in my car i have candy <huydw@proton.me>
2025-01-09 12:14:55 +00:00
a2bc06aee0 Implement qualification details (#26)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #26
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-09 11:48:39 +00:00
176074fbdc add form to edit employee
Reviewed-on: #23
Reviewed-by: Get in my car i have candy <huydw@proton.me>
2025-01-09 11:10:51 +00:00
b0009229d1 stop page reload after employee deletion (#25)
Reviewed-on: #25
Reviewed-by: Get in my car i have candy <huydw@proton.me>
Co-authored-by: Constantin Simonis <constantin@simonis.lol>
Co-committed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-09 11:06:30 +00:00
294191d24e Implement deleting qualifications (#24)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #24
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-09 11:01:21 +00:00
dc61810632 add form to create employee
Reviewed-on: #18
Reviewed-by: Get in my car i have candy <huydw@proton.me>
2025-01-09 09:59:01 +00:00
10ead075a7 [TASK] Fix small styling mistakes (#22)
Reviewed-on: #22
2025-01-09 09:55:16 +00:00
839f9f7752 change file structure
Reviewed-on: #21
Reviewed-by: Get in my car i have candy <huydw@proton.me>
2025-01-09 09:13:06 +00:00
ae7146d28f Implement editing qualifications (#20)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #20
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-08 11:25:32 +00:00
36d5a29b9e Implement error handling for the create qualification form (#19)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #19
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-08 10:31:38 +00:00
bd13dfe39e Implement creating qualifications (#17)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #17
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-08 09:12:10 +00:00
9779fdb3f5 refactor compose.yml
Reviewed-on: #16
2025-01-08 08:10:29 +00:00
4ab01752bf fix delete icon (#15)
Reviewed-on: #15
Co-authored-by: Constantin Simonis <constantin@simonis.lol>
Co-committed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-08 07:55:35 +00:00
b85360fb65 Add white background, replace constant reload with page reload on employee deletion (#14)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #14
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-01-08 07:51:13 +00:00
e0101a5364 Convert qualifications to table (#13)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #13
2024-12-18 13:36:55 +00:00
290da0e2a2 add interval for refreshing users (#12)
Reviewed-on: #12
Reviewed-by: Hernd Beidemann <huydw@proton.me>
2024-12-18 13:09:24 +00:00
0eb4fe419b add functionality to delete employee button (#11)
Reviewed-on: #11
Reviewed-by: Hernd Beidemann <huydw@proton.me>
2024-12-18 13:02:46 +00:00
4f31bc1358 [FEATURE] Display qualifications list in homepage (#10)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #10
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2024-12-18 12:28:13 +00:00
53c0fde21f style employee list (#6)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #6
Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Co-committed-by: Jan-Marlon Leibl <jleibl@proton.me>
2024-12-18 12:00:26 +00:00
da378e7555 Replace hardcoded bearer token (#9)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #9
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2024-12-18 11:10:23 +00:00
e2864fa439 Fix keycloak auth (#5)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #5
Co-authored-by: ptran <huydw@proton.me>
Co-committed-by: ptran <huydw@proton.me>
2024-12-18 10:47:09 +00:00
934716fb3e Configure keycloak (#4)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #4
Co-authored-by: ptran <huydw@proton.me>
Co-committed-by: ptran <huydw@proton.me>
2024-12-18 10:02:18 +00:00
4e27c3fb2e Create login route and protect routes (#3)
Co-authored-by: Phan Huy Tran <p.tran@neusta.de>
Reviewed-on: #3
Co-authored-by: ptran <huydw@proton.me>
Co-committed-by: ptran <huydw@proton.me>
2024-12-18 09:27:44 +00:00
57 changed files with 2381 additions and 203 deletions

View File

@ -1,11 +1,9 @@
# Starter für das LF10 Projekt
## Requirements
* Docker https://docs.docker.com/get-docker/
* Docker compose (bei Windows und Mac schon in Docker enthalten) https://docs.docker.com/compose/install/
- Docker https://docs.docker.com/get-docker/
- Docker compose (bei Windows und Mac schon in Docker enthalten) https://docs.docker.com/compose/install/
### Abhängigkeiten starten (Postgres, EmployeeBackend)
@ -37,7 +35,7 @@ http://localhost:8089/swagger
# Postgres
```
````
### Intellij-Ansicht für Postgres Datenbank einrichten (geht nicht in Webstorm!)
@ -51,7 +49,7 @@ http://localhost:8089/swagger
7. Username lf10_starter und Passwort secret eintragen (siehe application.properties), mit Apply bestätigen
8. im Reiter Schemas alle Häkchen entfernen und lediglich vor lf10_starter_db und public Häkchen setzen
9. mit Apply und ok bestätigen
```
````
# Keycloak
@ -80,57 +78,54 @@ die ClientId deines Angular Frontends lautet: employee-management-service-fronte
Hier ein Beispiel einer app.config.ts mit der Konfiguration für Keycloak. Mit dem KeycloakService, der hier definiert wird, kannst du in einem AuthGuard z.B. feststellen, ob der Benutzer eingeloggt ist oder nicht oder ihn mit keycloakService.login() zum Login weiterleiten.
```typescript
import {APP_INITIALIZER, ApplicationConfig} from '@angular/core';
import { provideRouter } from '@angular/router';
import { APP_INITIALIZER, ApplicationConfig } from "@angular/core";
import { provideRouter } from "@angular/router";
import { routes } from './app.routes';
import { routes } from "./app.routes";
import { KeycloakAngularModule, KeycloakBearerInterceptor, KeycloakService } from "keycloak-angular";
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http";
export const initializeKeycloak = (keycloak: KeycloakService) => async () =>
keycloak.init({
config: {
url: 'KEYCLOAK_URL',
realm: 'REALM',
clientId: 'CLIENT_ID',
url: "KEYCLOAK_URL",
realm: "REALM",
clientId: "CLIENT_ID",
},
loadUserProfileAtStartUp: true,
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri:
window.location.origin + '/silent-check-sso.html',
onLoad: "check-sso",
silentCheckSsoRedirectUri: window.location.origin + "/silent-check-sso.html",
checkLoginIframe: false,
redirectUri: 'http://localhost:4200',
redirectUri: "http://localhost:4200",
},
});
function initializeApp(keycloak: KeycloakService): () => Promise<boolean> {
return () => initializeKeycloak(keycloak)();
}
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes),
providers: [
provideRouter(routes),
KeycloakAngularModule,
{
provide: APP_INITIALIZER,
useFactory: initializeApp,
multi: true,
deps: [KeycloakService]
deps: [KeycloakService],
},
KeycloakService,
provideHttpClient(withInterceptorsFromDi()),
{
provide: HTTP_INTERCEPTORS,
useClass: KeycloakBearerInterceptor,
multi: true
}
]
multi: true,
},
],
};
```
Der Benutzer, mit dem ihr eure Integration testen könnt, hat den Benutzernamen user und das Passwort test. Die einzige Rolle heißt user.
Des Weiteren ist der Client mit der Bezeichnung employee-management-service-frontend wie folgt konfiguriert:
@ -141,4 +136,3 @@ Des Weiteren ist der Client mit der Bezeichnung employee-management-service-fron
# Bugs
Trage hier die Features ein, die nicht funktionieren. Beschreibe den jeweiligen Fehler.

View File

@ -16,9 +16,7 @@
"outputPath": "dist/lf10-starter2024",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [
{
@ -74,10 +72,7 @@
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
@ -91,11 +86,18 @@
],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
}
}
},
"cli": {
"analytics": "33c8483f-3876-4eb5-9c9b-1001cab9b273"
"analytics": "33c8483f-3876-4eb5-9c9b-1001cab9b273",
"schematicCollections": ["angular-eslint"]
}
}

BIN
bun.lockb

Binary file not shown.

View File

@ -1,11 +1,10 @@
volumes:
employee_postgres_data:
driver: local
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 +16,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

43
eslint.config.js Normal file
View File

@ -0,0 +1,43 @@
// @ts-check
const eslint = require("@eslint/js");
const tseslint = require("typescript-eslint");
const angular = require("angular-eslint");
module.exports = tseslint.config(
{
files: ["**/*.ts"],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
...angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
"@angular-eslint/directive-selector": [
"error",
{
type: "attribute",
prefix: "app",
style: "camelCase",
},
],
"@angular-eslint/component-selector": [
"error",
{
type: "element",
prefix: "app",
style: "kebab-case",
},
],
},
},
{
files: ["**/*.html"],
extends: [
...angular.configs.templateRecommended,
...angular.configs.templateAccessibility,
],
rules: {},
},
);

View File

@ -6,7 +6,8 @@
"start": "ng serve --proxy-config src/proxy.conf.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
"test": "ng test",
"lint": "ng lint"
},
"private": true,
"dependencies": {
@ -20,8 +21,11 @@
"@angular/platform-browser": "^19.0.4",
"@angular/platform-browser-dynamic": "^19.0.4",
"@angular/router": "^19.0.4",
"keycloak-angular": "^16.1.0",
"prettier": "^3.4.2",
"rxjs": "~7.8.1",
"tailwind": "4.0.0",
"tailwindcss": "^3.4.17",
"tslib": "^2.8.1",
"zone.js": "~0.15.0"
},
@ -30,12 +34,17 @@
"@angular/cli": "^19.0.5",
"@angular/compiler-cli": "^19.0.4",
"@types/jasmine": "~5.1.5",
"angular-eslint": "19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.16.0",
"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",
"typescript": "~5.5.4"
"postcss": "^8.4.49",
"typescript": "~5.5.4",
"typescript-eslint": "8.18.0"
}
}

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,10 @@
<app-employee-list></app-employee-list>
<main class="container mx-auto px-4 py-8 max-w-6xl">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-extrabold text-gray-900">{{ title }}</h1>
<button mat-flat-button class="!bg-red-600 !text-white" (click)="logout()">
<mat-icon>logout</mat-icon>
<span class="ml-1">Logout</span>
</button>
</div>
<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,22 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import {EmployeeListComponent} from "./employee-list/employee-list.component";
import { AuthService } from './services/auth.service';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-root',
imports: [CommonModule, EmployeeListComponent],
imports: [CommonModule, RouterOutlet, MatButtonModule, MatIconModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
standalone: true,
})
export class AppComponent {
title = 'lf10StarterNew';
title = 'Employee Management System';
constructor(private authService: AuthService) {}
logout(): void {
this.authService.logout();
}
}

View File

@ -1,10 +1,57 @@
import { ApplicationConfig } from '@angular/core';
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 {provideHttpClient, withInterceptorsFromDi} from "@angular/common/http";
import {
HTTP_INTERCEPTORS,
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 { 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,14 @@
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,102 @@
<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,124 @@
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,
})
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: Record<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: Record<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,50 @@
<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 } 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,
})
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,85 @@
<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,25 @@
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,
})
export class DetailsComponent {
employee: Employee = inject(MAT_DIALOG_DATA);
dialogRef: DialogRef = inject(DialogRef);
closeModal() {
this.dialogRef.close();
}
}

View File

@ -0,0 +1,102 @@
<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,136 @@
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,
})
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: Record<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: Record<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,183 @@
<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">
<div class="!flex !items-center !gap-4">
<h2 class="!text-2xl !font-semibold !text-gray-900 !shrink-0">
Employee Directory
</h2>
<mat-form-field class="!m-0" subscriptSizing="dynamic">
<mat-icon matPrefix class="!text-gray-400 !mr-2">search</mat-icon>
<input
matInput
type="text"
placeholder="Search employees..."
(keyup)="filterEmployees($event)"
/>
<div
matSuffix
class="!w-[24px] !h-[24px] !ml-2 !flex !items-center !justify-center"
>
<mat-progress-spinner
[diameter]="20"
mode="indeterminate"
[class.!opacity-0]="!isSearching"
[class.!opacity-100]="isSearching"
class="!transition-opacity"
></mat-progress-spinner>
</div>
</mat-form-field>
</div>
<button
mat-flat-button
color="primary"
class="!bg-blue-600 !text-white !shrink-0"
(click)="showCreateEmployeeModal()"
>
<mat-icon class="!mr-2">add</mat-icon>
Add Employee
</button>
</div>
@if (!employees || employees.length === 0) {
<div class="!bg-gray-50 !rounded-lg !p-8">
<div class="!text-center !max-w-sm !mx-auto">
<div
class="!bg-blue-100 !rounded-full !w-16 !h-16 !flex !items-center !justify-center !mx-auto !mb-4"
>
<mat-icon class="!text-blue-600 !w-8 !h-8 !text-3xl"
>people</mat-icon
>
</div>
<h3 class="!text-gray-900 !font-medium !text-lg !mb-2">
No employees found
</h3>
<p class="!text-gray-600 !mb-6">
Get started by adding your first employee to the directory.
</p>
<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>
</div>
} @else {
<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>
<button
class="text-blue-600 hover:underline cursor-pointer"
[matTooltip]="'View Employee details'"
(click)="openDetailModal(employee)"
(keydown.enter)="openDetailModal(employee)"
>
{{ employee.lastName }}, {{ employee.firstName }}
</button>
</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>
}
</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 {
<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 loading the employees.
</p>
<p class="text-gray-600 mt-1 text-xs md:text-sm">
Please try refreshing the page.
</p>
</div>
</div>
</div>
} @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,152 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
catchError,
debounceTime,
distinctUntilChanged,
Observable,
of,
retry,
Subject,
} 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 { 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';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ErrorHandlerService } from '../../services/error.handler.service';
@Component({
selector: 'app-employee-list',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatSnackBarModule,
MatDividerModule,
MatTooltipModule,
MatMenuModule,
MatTableModule,
MatSortModule,
MatFormFieldModule,
MatInputModule,
],
templateUrl: './table.component.html',
})
export class TableComponent implements OnInit {
private readonly apiService: EmployeeApiService = inject(EmployeeApiService);
private readonly matDialog: MatDialog = inject(MatDialog);
private readonly errorHandlerService: ErrorHandlerService =
inject(ErrorHandlerService);
private static readonly MAX_RETRIES = 3;
private allEmployees: Employee[] = [];
private searchSubject = new Subject<string>();
public employees$: Observable<Employee[]> = of([]);
public isSearching = false;
public readonly displayedColumns: string[] = ['name', 'actions'];
public ngOnInit(): void {
this.loadEmployees();
this.setupSearch();
}
private loadEmployees(): void {
this.fetchEmployees().subscribe((employees) => {
this.allEmployees = employees;
this.employees$ = of(employees);
});
}
private setupSearch(): void {
this.searchSubject
.pipe(debounceTime(300), distinctUntilChanged())
.subscribe((searchTerm) => {
this.isSearching = true;
setTimeout(() => {
const filteredEmployees = this.allEmployees.filter(
(employee) =>
employee.firstName?.toLowerCase().includes(searchTerm) ||
employee.lastName?.toLowerCase().includes(searchTerm),
);
this.employees$ = of(filteredEmployees);
this.isSearching = false;
}, 150);
});
}
private fetchEmployees(): Observable<Employee[]> {
return this.apiService.getAll().pipe(
retry(TableComponent.MAX_RETRIES),
catchError((error: HttpErrorResponse) => {
console.error('Error fetching employees:', error);
this.errorHandlerService.showErrorMessage(
'Failed to load employees. Please try again.',
);
return of([]);
}),
);
}
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 });
}
protected filterEmployees(event: Event): void {
const searchTerm = (event.target as HTMLInputElement).value.toLowerCase();
this.searchSubject.next(searchTerm);
}
}

View File

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

View File

@ -0,0 +1,10 @@
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',
})
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,3 @@
<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 = '';
private maxDots = 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,6 @@
export class Qualification {
constructor(
public id: number,
public skill?: string,
) {}
}

View File

@ -0,0 +1,71 @@
<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 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 creating the qualification.
</p>
<p class="text-gray-600 mt-1 text-xs md:text-sm">
{{ apiErrorMessage }}
</p>
</div>
</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,93 @@
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';
@Component({
selector: 'app-create-qualification',
imports: [
ReactiveFormsModule,
MatError,
NgIf,
MatLabel,
MatDialogTitle,
MatDialogContent,
MatFormField,
MatDialogActions,
MatButton,
MatInput,
MatDialogClose,
MatHint,
MatIcon,
],
templateUrl: './create.component.html',
})
export class CreateComponent {
private formBuilder: FormBuilder = inject(FormBuilder);
private qualificationService: QualificationService =
inject(QualificationService);
private dialogRef: MatDialogRef<CreateComponent> = inject(MatDialogRef);
public apiErrorMessage = '';
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;
}
const qualification = {
skill: this.qualificationForm.value.skill || '',
};
this.qualificationService.create(qualification).subscribe({
next: (createdQualification) => {
this.dialogRef.close(createdQualification);
},
error: (error) => {
console.error('Error creating qualification:', error);
this.apiErrorMessage = 'API Error';
},
});
}
}

View File

@ -0,0 +1,64 @@
<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,59 @@
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 { MatIcon } from '@angular/material/icon';
@Component({
selector: 'app-delete-qualification',
imports: [
FormsModule,
MatDialogContent,
MatDialogTitle,
ReactiveFormsModule,
MatDialogActions,
MatButton,
MatIcon,
],
templateUrl: './delete.component.html',
standalone: true,
})
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,59 @@
<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) {
<button
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 text-left"
(click)="openEmployeeDetailsModal(employee.id)"
(keydown.enter)="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>
</button>
}
</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,56 @@
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',
})
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,71 @@
<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 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 editing the qualification.
</p>
<p class="text-gray-600 mt-1 text-xs md:text-sm">
{{ apiErrorMessage }}
</p>
</div>
</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,104 @@
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',
})
export class EditComponent {
public apiErrorMessage = '';
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;
}
const qualification = {
skill: this.qualificationForm.value.skill || '',
};
this.qualificationService
.update(this.qualification.id, qualification)
.subscribe({
next: (editedQualification) => {
this.dialogRef.close(editedQualification);
},
error: (error) => {
console.error('Error updating qualification:', error);
this.apiErrorMessage = 'API Error';
},
});
}
}

View File

@ -0,0 +1,190 @@
<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">
<div class="!flex !items-center !gap-4">
<h2 class="!text-2xl !font-semibold !text-gray-900 !shrink-0">
Qualifications
</h2>
<mat-form-field class="!m-0" subscriptSizing="dynamic">
<mat-icon matPrefix class="!text-gray-400 !mr-2">search</mat-icon>
<input
matInput
type="text"
placeholder="Search qualifications..."
(keyup)="filterQualifications($event)"
/>
<div
matSuffix
class="!w-[24px] !h-[24px] !ml-2 !flex !items-center !justify-center"
>
<mat-progress-spinner
[diameter]="20"
mode="indeterminate"
[class.!opacity-0]="!isSearching"
[class.!opacity-100]="isSearching"
class="!transition-opacity"
></mat-progress-spinner>
</div>
</mat-form-field>
</div>
<button
mat-flat-button
color="primary"
class="!bg-blue-600 !text-white !shrink-0"
(click)="openCreateModal()"
>
<mat-icon class="!mr-2">add</mat-icon>
Add Qualification
</button>
</div>
@if (!qualifications || qualifications.length === 0) {
<div class="!bg-gray-50 !rounded-lg !p-8">
<div class="!text-center !max-w-sm !mx-auto">
<div
class="!bg-blue-100 !rounded-full !w-16 !h-16 !flex !items-center !justify-center !mx-auto !mb-4"
>
<mat-icon class="!text-blue-600 !w-8 !h-8 !text-3xl"
>school</mat-icon
>
</div>
<h3 class="!text-gray-900 !font-medium !text-lg !mb-2">
No qualifications found
</h3>
<p class="!text-gray-600 !mb-6">
Get started by adding your first qualification to the directory.
</p>
<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>
</div>
} @else {
<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>
<button
class="text-blue-600 hover:underline cursor-pointer"
[matTooltip]="'View qualification details'"
(click)="openDetailsModal(qualification)"
(keydown.enter)="openDetailsModal(qualification)"
>
{{ qualification.skill }}
</button>
</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)"
(keydown.enter)="openEditModal(qualification)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
[matTooltip]="'Delete qualification'"
(click)="openDeleteModal(qualification.id)"
(keydown.enter)="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>
}
</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 {
<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 loading the qualifications.
</p>
<p class="text-gray-600 mt-1 text-xs md:text-sm">
Please try refreshing the page.
</p>
</div>
</div>
</div>
} @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,154 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
catchError,
debounceTime,
distinctUntilChanged,
Observable,
of,
retry,
Subject,
} from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';
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 { 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 { 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 { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { DetailsComponent } from '../details/details.component';
import { ErrorHandlerService } from '../../services/error.handler.service';
@Component({
selector: 'app-qualifications',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatSnackBarModule,
MatDividerModule,
MatTooltipModule,
MatMenuModule,
MatTableModule,
MatSortModule,
MatFormFieldModule,
MatInputModule,
],
templateUrl: './table.component.html',
})
export class QualificationsComponent implements OnInit {
private readonly qualificationService: QualificationService =
inject(QualificationService);
private readonly errorHandlerService: ErrorHandlerService =
inject(ErrorHandlerService);
private readonly dialog: MatDialog = inject(MatDialog);
private static readonly MAX_RETRIES = 3;
private allQualifications: Qualification[] = [];
private searchSubject = new Subject<string>();
public qualifications$: Observable<Qualification[]> = of([]);
public isSearching = false;
public readonly displayedColumns: string[] = ['skill', 'actions'];
ngOnInit() {
this.loadQualifications();
this.setupSearch();
}
private loadQualifications(): void {
this.fetchQualifications().subscribe((qualifications) => {
this.allQualifications = qualifications;
this.qualifications$ = of(qualifications);
});
}
private setupSearch(): void {
this.searchSubject
.pipe(debounceTime(300), distinctUntilChanged())
.subscribe((searchTerm) => {
this.isSearching = true;
setTimeout(() => {
const filteredQualifications = this.allQualifications.filter(
(qualification) =>
qualification.skill?.toLowerCase().includes(searchTerm),
);
this.qualifications$ = of(filteredQualifications);
this.isSearching = false;
}, 150);
});
}
private fetchQualifications(): Observable<Qualification[]> {
return this.qualificationService.getAll().pipe(
retry(QualificationsComponent.MAX_RETRIES),
catchError((error: HttpErrorResponse) => {
console.error('Error fetching qualifications:', error);
this.errorHandlerService.showErrorMessage(
'Failed to load qualifications. Please try again.',
);
return of([]);
}),
);
}
protected filterQualifications(event: Event): void {
const searchTerm = (event.target as HTMLInputElement).value.toLowerCase();
this.searchSubject.next(searchTerm);
}
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) {
this.dialog.open(DetailsComponent, {
data: qualification,
});
}
}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import { AuthService } from './auth.service';
import { Router } from '@angular/router';
@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,23 @@
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;
}
public logout(): void {
this.keycloakService.logout();
}
}

View File

@ -0,0 +1,41 @@
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 = '/backend';
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,18 @@
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
@Injectable({
providedIn: 'root',
})
export class ErrorHandlerService {
private readonly snackBar: MatSnackBar = inject(MatSnackBar);
public 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'],
});
}
}

View File

@ -0,0 +1,53 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Qualification } from '../qualification/Qualification';
import { Employee } from '../employee/Employee';
@Injectable({
providedIn: 'root',
})
export default class QualificationService {
private static readonly BASE_URL = '/backend';
private readonly apiUrl = `${QualificationService.BASE_URL}/qualifications`;
constructor(private http: HttpClient) {}
getAll(): Observable<Qualification[]> {
return this.http
.get<Qualification[]>(this.apiUrl)
.pipe(
map((qualifications) => qualifications.sort((a, b) => a.id - b.id)),
);
}
getById(id: number): Observable<Qualification> {
return this.http.get<Qualification>(`${this.apiUrl}/${id}`);
}
create(qualification: Omit<Qualification, 'id'>): Observable<Qualification> {
return this.http.post<Qualification>(this.apiUrl, qualification);
}
update(
id: number,
qualification: Partial<Qualification>,
): Observable<Qualification> {
return this.http.put<Qualification>(`${this.apiUrl}/${id}`, qualification);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
findEmployees(id: number): Observable<Employee[]> {
interface EmployeeResponse {
employees: Employee[];
}
return this.http
.get<EmployeeResponse>(`${this.apiUrl}/${id}/employees`)
.pipe(map((response) => response.employees));
}
}

View File

@ -1,15 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Lf10StarterNew</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">
<meta charset="utf-8" />
<title>Employee Management System</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">
<body class="mat-typography bg-white">
<app-root></app-root>
</body>
</html>

View File

@ -2,5 +2,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));
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err),
);

View File

@ -1,4 +1,12 @@
/* 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; }
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: Roboto, "Helvetica Neue", sans-serif;
}

8
tailwind.config.js Normal file
View File

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

View File

@ -6,10 +6,6 @@
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

View File

@ -19,10 +19,7 @@
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": [
"ES2022",
"dom"
]
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,

View File

@ -4,12 +4,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
"types": ["jasmine"]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}