Compare commits
43 Commits
task/remov
...
main
Author | SHA1 | Date | |
---|---|---|---|
807a8cff3f | |||
ae2478a577 | |||
d0a1248519 | |||
545c6194e4 | |||
88d9a1a534 | |||
9d22662cf1 | |||
905ddcdf2a | |||
c48d68b87c | |||
ed1696c21d | |||
90896a2527 | |||
9bfbf28b98 | |||
876c386944 | |||
7ea79c64ef | |||
417acde6ac | |||
9d7744476f | |||
ebdb2eeedf | |||
c06e5a8a2e | |||
d00aec70a0 | |||
14cd210a05 | |||
dc4c02e0d7 | |||
9104dfa541 | |||
1e52741155 | |||
cd36904d45 | |||
5829876444 | |||
0782095970 | |||
9ceb0b803e | |||
0caefccc70 | |||
10f1e09ccd | |||
c938ef8465 | |||
fc6ea3b907 | |||
b7aa0471ad | |||
1eca1906cc | |||
f7131a6b8d | |||
5ba4db66af | |||
90cccf4fdb | |||
611a158d2d | |||
c39229dc94 | |||
e0b3d7267f | |||
e4f811bdae | |||
725fbff5e5 | |||
ca2cddad94 | |||
779a4799c6 | |||
f1f58b73c9 |
72
README.md
72
README.md
@ -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: [
|
||||||
KeycloakAngularModule,
|
provideRouter(routes),
|
||||||
{
|
KeycloakAngularModule,
|
||||||
provide: APP_INITIALIZER,
|
{
|
||||||
useFactory: initializeApp,
|
provide: APP_INITIALIZER,
|
||||||
multi: true,
|
useFactory: initializeApp,
|
||||||
deps: [KeycloakService]
|
multi: true,
|
||||||
},
|
deps: [KeycloakService],
|
||||||
KeycloakService,
|
},
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
KeycloakService,
|
||||||
{
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provide: HTTP_INTERCEPTORS,
|
{
|
||||||
useClass: KeycloakBearerInterceptor,
|
provide: HTTP_INTERCEPTORS,
|
||||||
multi: true
|
useClass: KeycloakBearerInterceptor,
|
||||||
}
|
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.
|
||||||
|
|
||||||
|
18
angular.json
18
angular.json
@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
43
eslint.config.js
Normal 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: {},
|
||||||
|
},
|
||||||
|
);
|
13
package.json
13
package.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
7
public/silent-check-sso.html
Normal file
7
public/silent-check-sso.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
parent.postMessage(location.href, location.origin);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -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) {
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
||||||
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
@ -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: '' },
|
||||||
|
];
|
||||||
|
@ -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>
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
@ -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}`)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
14
src/app/employee/Employee.ts
Normal file
14
src/app/employee/Employee.ts
Normal 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[],
|
||||||
|
) {}
|
||||||
|
}
|
102
src/app/employee/create/create.component.html
Normal file
102
src/app/employee/create/create.component.html
Normal 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>
|
124
src/app/employee/create/create.component.ts
Normal file
124
src/app/employee/create/create.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
50
src/app/employee/delete/delete.component.html
Normal file
50
src/app/employee/delete/delete.component.html
Normal 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>
|
38
src/app/employee/delete/delete.component.ts
Normal file
38
src/app/employee/delete/delete.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
85
src/app/employee/details/details.component.html
Normal file
85
src/app/employee/details/details.component.html
Normal 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>
|
25
src/app/employee/details/details.component.ts
Normal file
25
src/app/employee/details/details.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
102
src/app/employee/edit/edit.component.html
Normal file
102
src/app/employee/edit/edit.component.html
Normal 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>
|
136
src/app/employee/edit/edit.component.ts
Normal file
136
src/app/employee/edit/edit.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
183
src/app/employee/table/table.component.html
Normal file
183
src/app/employee/table/table.component.html
Normal 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>
|
152
src/app/employee/table/table.component.ts
Normal file
152
src/app/employee/table/table.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
2
src/app/home/home.component.html
Normal file
2
src/app/home/home.component.html
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<app-employee-list></app-employee-list>
|
||||||
|
<app-qualifications></app-qualifications>
|
10
src/app/home/home.component.ts
Normal file
10
src/app/home/home.component.ts
Normal 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 {}
|
6
src/app/login/login.component.css
Normal file
6
src/app/login/login.component.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.dot-loader {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #555;
|
||||||
|
text-align: center;
|
||||||
|
}
|
3
src/app/login/login.component.html
Normal file
3
src/app/login/login.component.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<div class="dot-loader">
|
||||||
|
Logging in<span>{{ dots }}</span>
|
||||||
|
</div>
|
27
src/app/login/login.component.ts
Normal file
27
src/app/login/login.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
6
src/app/qualification/Qualification.ts
Normal file
6
src/app/qualification/Qualification.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export class Qualification {
|
||||||
|
constructor(
|
||||||
|
public id: number,
|
||||||
|
public skill?: string,
|
||||||
|
) {}
|
||||||
|
}
|
71
src/app/qualification/create/create.component.html
Normal file
71
src/app/qualification/create/create.component.html
Normal 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>
|
93
src/app/qualification/create/create.component.ts
Normal file
93
src/app/qualification/create/create.component.ts
Normal 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';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
64
src/app/qualification/delete/delete.component.html
Normal file
64
src/app/qualification/delete/delete.component.html
Normal 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>
|
59
src/app/qualification/delete/delete.component.ts
Normal file
59
src/app/qualification/delete/delete.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
59
src/app/qualification/details/details.component.html
Normal file
59
src/app/qualification/details/details.component.html
Normal 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>
|
56
src/app/qualification/details/details.component.ts
Normal file
56
src/app/qualification/details/details.component.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
71
src/app/qualification/edit/edit.component.html
Normal file
71
src/app/qualification/edit/edit.component.html
Normal 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>
|
104
src/app/qualification/edit/edit.component.ts
Normal file
104
src/app/qualification/edit/edit.component.ts
Normal 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';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
190
src/app/qualification/table/table.component.html
Normal file
190
src/app/qualification/table/table.component.html
Normal 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>
|
154
src/app/qualification/table/table.component.ts
Normal file
154
src/app/qualification/table/table.component.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
23
src/app/services/auth-guard.service.ts
Normal file
23
src/app/services/auth-guard.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
23
src/app/services/auth.service.ts
Normal file
23
src/app/services/auth.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
41
src/app/services/employee-api.service.ts
Normal file
41
src/app/services/employee-api.service.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
18
src/app/services/error.handler.service.ts
Normal file
18
src/app/services/error.handler.service.ts
Normal 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'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
53
src/app/services/qualification.service.ts
Normal file
53
src/app/services/qualification.service.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
@ -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"
|
||||||
</head>
|
rel="stylesheet"
|
||||||
<body class="mat-typography">
|
/>
|
||||||
<app-root></app-root>
|
<link
|
||||||
</body>
|
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body class="mat-typography bg-white">
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -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),
|
||||||
|
);
|
||||||
|
@ -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
8
tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./src/**/*.{html,ts,css,scss,sass,less,styl}"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user