43 Commits

Author SHA1 Message Date
807a8cff3f add logout button and update title in header (#50)
Reviewed-on: http://git.simonis.lol/angular/ems-frontend/pulls/50
Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Co-committed-by: Jan-Marlon Leibl <jleibl@proton.me>
2025-01-23 12:00:50 +00:00
ae2478a577 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
d0a1248519 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
545c6194e4 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
88d9a1a534 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
9d22662cf1 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
905ddcdf2a 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
c48d68b87c 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
ed1696c21d 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
90896a2527 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
9bfbf28b98 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
876c386944 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
7ea79c64ef 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
417acde6ac 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
9d7744476f 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
ebdb2eeedf 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
c06e5a8a2e 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
Huy
d00aec70a0 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
14cd210a05 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
dc4c02e0d7 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
9104dfa541 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
Huy
1e52741155 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
cd36904d45 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
5829876444 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
Huy
0782095970 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
9ceb0b803e 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
0caefccc70 [TASK] Fix small styling mistakes (#22)
Reviewed-on: #22
2025-01-09 09:55:16 +00:00
10f1e09ccd 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
Huy
c938ef8465 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
Huy
fc6ea3b907 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
Huy
b7aa0471ad 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
1eca1906cc refactor compose.yml
Reviewed-on: #16
2025-01-08 08:10:29 +00:00
f7131a6b8d 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
Huy
5ba4db66af 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
Huy
90cccf4fdb 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
611a158d2d add interval for refreshing users (#12)
Reviewed-on: #12
Reviewed-by: Hernd Beidemann <huydw@proton.me>
2024-12-18 13:09:24 +00:00
c39229dc94 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
Huy
e0b3d7267f [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
e4f811bdae 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
Huy
725fbff5e5 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
Huy
ca2cddad94 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
Huy
779a4799c6 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
Huy
f1f58b73c9 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 # Starter für das LF10 Projekt
## Requirements ## Requirements
* Docker https://docs.docker.com/get-docker/ - Docker https://docs.docker.com/get-docker/
* Docker compose (bei Windows und Mac schon in Docker enthalten) https://docs.docker.com/compose/install/ - Docker compose (bei Windows und Mac schon in Docker enthalten) https://docs.docker.com/compose/install/
### Abhängigkeiten starten (Postgres, EmployeeBackend) ### Abhängigkeiten starten (Postgres, EmployeeBackend)
@ -37,7 +35,7 @@ http://localhost:8089/swagger
# Postgres # Postgres
``` ````
### Intellij-Ansicht für Postgres Datenbank einrichten (geht nicht in Webstorm!) ### 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 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 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 9. mit Apply und ok bestätigen
``` ````
# Keycloak # 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. 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 ```typescript
import {APP_INITIALIZER, ApplicationConfig} from '@angular/core'; import { APP_INITIALIZER, ApplicationConfig } from "@angular/core";
import { provideRouter } from '@angular/router'; import { provideRouter } from "@angular/router";
import { routes } from './app.routes'; import { routes } from "./app.routes";
import { KeycloakAngularModule, KeycloakBearerInterceptor, KeycloakService } from "keycloak-angular"; import { KeycloakAngularModule, KeycloakBearerInterceptor, KeycloakService } from "keycloak-angular";
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http";
export const initializeKeycloak = (keycloak: KeycloakService) => async () => export const initializeKeycloak = (keycloak: KeycloakService) => async () =>
keycloak.init({ keycloak.init({
config: { config: {
url: 'KEYCLOAK_URL', url: "KEYCLOAK_URL",
realm: 'REALM', realm: "REALM",
clientId: 'CLIENT_ID', clientId: "CLIENT_ID",
}, },
loadUserProfileAtStartUp: true, loadUserProfileAtStartUp: true,
initOptions: { initOptions: {
onLoad: 'check-sso', onLoad: "check-sso",
silentCheckSsoRedirectUri: silentCheckSsoRedirectUri: window.location.origin + "/silent-check-sso.html",
window.location.origin + '/silent-check-sso.html',
checkLoginIframe: false, checkLoginIframe: false,
redirectUri: 'http://localhost:4200', redirectUri: "http://localhost:4200",
}, },
}); });
function initializeApp(keycloak: KeycloakService): () => Promise<boolean> { function initializeApp(keycloak: KeycloakService): () => Promise<boolean> {
return () => initializeKeycloak(keycloak)(); return () => initializeKeycloak(keycloak)();
} }
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), providers: [
provideRouter(routes),
KeycloakAngularModule, KeycloakAngularModule,
{ {
provide: APP_INITIALIZER, provide: APP_INITIALIZER,
useFactory: initializeApp, useFactory: initializeApp,
multi: true, multi: true,
deps: [KeycloakService] deps: [KeycloakService],
}, },
KeycloakService, KeycloakService,
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
{ {
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,
useClass: KeycloakBearerInterceptor, 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. 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: 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 # Bugs
Trage hier die Features ein, die nicht funktionieren. Beschreibe den jeweiligen Fehler. Trage hier die Features ein, die nicht funktionieren. Beschreibe den jeweiligen Fehler.

View File

@ -16,9 +16,7 @@
"outputPath": "dist/lf10-starter2024", "outputPath": "dist/lf10-starter2024",
"index": "src/index.html", "index": "src/index.html",
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": [ "polyfills": ["zone.js"],
"zone.js"
],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"assets": [ "assets": [
{ {
@ -74,10 +72,7 @@
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular-devkit/build-angular:karma",
"options": { "options": {
"polyfills": [ "polyfills": ["zone.js", "zone.js/testing"],
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"assets": [ "assets": [
{ {
@ -91,11 +86,18 @@
], ],
"scripts": [] "scripts": []
} }
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
} }
} }
} }
}, },
"cli": { "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: volumes:
employee_postgres_data: employee_postgres_data:
driver: local driver: local
services: services:
postgres-employee: postgres-employee:
container_name: postgres_employee container_name: ems-db
image: postgres:13.3 image: postgres:13.3
volumes: volumes:
- employee_postgres_data:/var/lib/postgresql/data - employee_postgres_data:/var/lib/postgresql/data
@ -17,9 +16,8 @@ services:
- "5432:5432" - "5432:5432"
employee: employee:
container_name: employee container_name: ems-api
image: berndheidemann/employee-management-service:1.1.3 image: berndheidemann/employee-management-service:1.1.3
# image: berndheidemann/employee-management-service_without_keycloak:1.1
environment: environment:
spring.datasource.url: jdbc:postgresql://postgres-employee:5432/employee_db spring.datasource.url: jdbc:postgresql://postgres-employee:5432/employee_db
spring.datasource.username: employee 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", "start": "ng serve --proxy-config src/proxy.conf.json",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"lint": "ng lint"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
@ -20,8 +21,11 @@
"@angular/platform-browser": "^19.0.4", "@angular/platform-browser": "^19.0.4",
"@angular/platform-browser-dynamic": "^19.0.4", "@angular/platform-browser-dynamic": "^19.0.4",
"@angular/router": "^19.0.4", "@angular/router": "^19.0.4",
"keycloak-angular": "^16.1.0",
"prettier": "^3.4.2",
"rxjs": "~7.8.1", "rxjs": "~7.8.1",
"tailwind": "4.0.0", "tailwind": "4.0.0",
"tailwindcss": "^3.4.17",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
@ -30,12 +34,17 @@
"@angular/cli": "^19.0.5", "@angular/cli": "^19.0.5",
"@angular/compiler-cli": "^19.0.4", "@angular/compiler-cli": "^19.0.4",
"@types/jasmine": "~5.1.5", "@types/jasmine": "~5.1.5",
"angular-eslint": "19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.16.0",
"jasmine-core": "~5.2.0", "jasmine-core": "~5.2.0",
"karma": "~6.4.4", "karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1", "karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.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 { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router'; 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({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [CommonModule, EmployeeListComponent], imports: [CommonModule, RouterOutlet, MatButtonModule, MatIconModule],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.css' standalone: true,
}) })
export class AppComponent { 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 { provideRouter } from '@angular/router';
import {
KeycloakAngularModule,
KeycloakBearerInterceptor,
KeycloakService,
} from 'keycloak-angular';
import { routes } from './app.routes'; 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'; 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 = { 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,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> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<title>Lf10StarterNew</title> <title>Employee Management System</title>
<base href="/"> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico"> <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
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> 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> </head>
<body class="mat-typography"> <body class="mat-typography bg-white">
<app-root></app-root> <app-root></app-root>
</body> </body>
</html> </html>

View File

@ -2,5 +2,6 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component'; import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig) bootstrapApplication(AppComponent, appConfig).catch((err) =>
.catch((err) => console.error(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%; } html,
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 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", "outDir": "./out-tsc/app",
"types": [] "types": []
}, },
"files": [ "files": ["src/main.ts"],
"src/main.ts" "include": ["src/**/*.d.ts"]
],
"include": [
"src/**/*.d.ts"
]
} }

View File

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

View File

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