Capstone - Employee Management System

By Syncfusion®, Inc. · Last updated

Build a complete Employee Management System: project structure, authentication and roles, reusable components, lazy routing, a signal store, reactive forms with validation, API integration, error handling and performance.

Project Overview

In this lesson, you build an Employee Management System: an app for an HR team to view and manage the employees of a company. It uses the most important topics of this course in one project.

Real-World Example

Most companies have an internal HR app like this one. HR administrators add new employees, update positions and salaries, and mark employees who have left as inactive. Team leads may only look up their colleagues.

The app must load fast, show clear messages when the server fails, and never let a viewer change employee data.

What the finished app can do:

  • Log in with email and password. Two roles: HR admin and viewer.
  • List employees with search, department filter and an "active" counter.
  • View the details of one employee.
  • Add, edit and delete employees (HR admin only), with form validation.
  • Protect pages with route guards, and log out automatically when the token expires.
  • Handle errors with clear messages and a global error banner.
FeatureLesson where you learned it
Components, @Input(), content projectionComponents, Component Communication
Routing, route parameters, lazy loadingRouting
Services and dependency injectionServices, Dependency Injection
Reactive forms and validatorsReactive Forms, Form Validation
HttpClient and interceptorsAPI Integration
Signals and a store serviceAngular Signals, State Management
JWT, guards and rolesUser Access Control
ErrorHandler, OnPush, trackError Handling, Performance Optimization

Project Setup

The project starts like every Angular app:

ng new employee-management
cd employee-management
ng generate environments

Then it gets a small Node.js API server in server/server.js, which you know from earlier capstone projects. The server:

  • signs JWTs at login (POST /api/auth/login),
  • answers the employee API (GET, POST, PUT and DELETE on /api/employees),
  • returns 401 without a valid token and 403 when a viewer tries to change data.

Two scripts in package.json start the two parts:

"scripts": {
  "start": "ng serve",
  "api": "node server/server.js"
}

The practice blocks on this page show each part of the app in a small, runnable form, with a practice server in the browser. The complete project, with the real API server, is in the download at the end.

Application Structure

The code is grouped by feature and by job. Each folder has one clear purpose:

src/app/
├── auth/           AuthService, interceptor, guards, user model
├── core/           global error handler, error banner state
├── shared/         reusable components: page header, status badge, search box
├── employees/      everything about employees
│   ├── employee.model.ts
│   ├── employee-api.service.ts     HTTP requests
│   ├── employee-store.ts           state with signals
│   ├── employee-validators.ts      custom validator
│   ├── employee-list.ts
│   ├── employee-detail.ts
│   ├── employee-form.ts
│   └── employees.routes.ts         lazy-loaded routes
├── pages/          login, access denied, not found
├── app.routes.ts
├── app.config.ts
└── app.ts

Componentslist, details, formEmployeeStoresignals + methodsEmployee APIEmployeeApiServiceInterceptoradds the tokenAPI serverJWT + role checksData moves in one direction. Components never call HttpClient directly.

The layers of the Employee Management System.

Data always moves in the same direction: components call the store, the store calls the API service, and the API service talks to the server. Components never call HttpClient directly.

Authentication

Authentication is the AuthService from the User Access Control lesson, without changes:

  • login() sends the email and password and stores the JWT in a signal and in localStorage.
  • user, isLoggedIn and isAdmin are computed from the token's payload.
  • A timer logs the user out when the token expires.
  • authInterceptor adds Authorization: Bearer <token> to every request and logs out on 401.
// auth/auth.service.ts (shortened)
readonly user = computed(() => {
  const token = this._token();
  return token ? decodeToken(token) : null;
});
readonly isLoggedIn = computed(() => this.user() !== null);
readonly isAdmin = computed(() => this.user()?.role === 'admin');

The login page uses a template-driven form with ngModel. After a successful login, it opens the page that the user wanted, or /employees.

Authorization

The app checks permissions in three places:

PlaceWhat it doesExample
Route guardsBlock a whole page/employees/new only for admins
TemplatesHide buttons the user may not useAdd, Edit and Delete only for admins
The serverRefuses forbidden requests with 403A viewer's DELETE request fails

The server check is the one that really protects the data. The other two give a better experience.

Role-based buttons and routes

import { Component, Injectable, computed, inject, signal } from '@angular/core';
import { CanActivateFn, Router, RouterLink, RouterOutlet, Routes } from '@angular/router';

@Injectable({ providedIn: 'root' })
export class AuthService {
  readonly role = signal<string | null>('user');
  readonly isAdmin = computed(() => this.role() === 'admin');
}

export const roleGuard: CanActivateFn = route => {
  const allowed: string[] = route.data['roles'] ?? [];
  return allowed.includes(inject(AuthService).role() ?? '') ? true : inject(Router).createUrlTree(['/forbidden']);
};

@Component({
  selector: 'app-employee-list',
  imports: [RouterLink],
  template: `
    <h4>Employees</h4>
    @if (auth.isAdmin()) {
      <p><a routerLink="/new">+ Add employee</a></p>
    }
    <p>Asha Rao, Ravi Kumar, Meena Iyer</p>
  `,
})
export class EmployeeList {
  auth = inject(AuthService);
}

@Component({ selector: 'app-employee-form', template: `<h4>Add employee</h4><p>The form for HR admins.</p>` })
export class EmployeeForm {}

@Component({ selector: 'app-forbidden', template: `<p>⛔ Only HR administrators can open this page.</p>` })
export class Forbidden {}

export const routes: Routes = [
  { path: '', component: EmployeeList },
  { path: 'new', component: EmployeeForm, canActivate: [roleGuard], data: { roles: ['admin'] } },
  { path: 'forbidden', component: Forbidden },
];

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, RouterLink],
  template: `
    <p>
      Role: <b>{{ auth.role() }}</b>
      <button (click)="auth.role.set('admin')">HR admin</button>
      <button (click)="auth.role.set('user')">Viewer</button>
      | <a routerLink="/">List</a> | <a routerLink="/new">/new (typed URL)</a>
    </p>
    <router-outlet />
  `,
})
export class App {
  auth = inject(AuthService);
}

As a viewer, the Add employee link is hidden, and opening /new directly shows the "access denied" page. Click HR admin and try again.

Reusable Components

Three small components in shared/ are used on several pages:

  • PageHeader: a title, a subtitle, and an area for buttons. The buttons are passed in with <ng-content />, so each page can add its own.
  • StatusBadge: a green "Active" or grey "Inactive" label.
  • SearchBox: an input that reports its text with an @Output().

All three use OnPush and get their data only through inputs, so Angular checks them only when an input changes.

Reusable PageHeader and StatusBadge

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';

@Component({
  selector: 'app-page-header',
  template: `
    <div class="header">
      <div>
        <h3>{{ title }}</h3>
        @if (subtitle) {
          <p class="subtitle">{{ subtitle }}</p>
        }
      </div>
      <div><ng-content /></div>
    </div>
  `,
  styles: `
    .header { display: flex; justify-content: space-between; align-items: center; }
    h3 { margin: 0; }
    .subtitle { margin: 2px 0 0; color: #6b7280; }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PageHeader {
  @Input() title = '';
  @Input() subtitle = '';
}

@Component({
  selector: 'app-status-badge',
  template: `<span [class.active]="active">{{ active ? 'Active' : 'Inactive' }}</span>`,
  styles: `
    span { padding: 2px 10px; border-radius: 10px; font-size: 13px; background: #e5e7eb; }
    span.active { background: #dcfce7; color: #166534; }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StatusBadge {
  @Input() active = true;
}

@Component({
  selector: 'app-root',
  imports: [PageHeader, StatusBadge],
  template: `
    <app-page-header title="Employees" subtitle="3 active of 4">
      <button>+ Add employee</button>
    </app-page-header>
    @for (employee of employees; track employee.id) {
      <p>{{ employee.name }} <app-status-badge [active]="employee.active" /></p>
    }
    <app-page-header title="Asha Rao" subtitle="Frontend Developer">
      <button>Edit</button> <button>Delete</button>
    </app-page-header>
  `,
})
export class App {
  employees = [
    { id: 1, name: 'Asha Rao', active: true },
    { id: 2, name: 'John Peter', active: false },
  ];
}

The same PageHeader shows one button on the list page and two buttons on the details page.

Routing

The app has public routes, protected routes and a lazy-loaded employee section:

// app.routes.ts
export const routes: Routes = [
  { path: '', redirectTo: 'employees', pathMatch: 'full' },
  { path: 'login', component: Login, canActivate: [guestGuard] },
  {
    path: 'employees',
    canActivate: [authGuard],
    loadChildren: () => import('./employees/employees.routes').then(m => m.EMPLOYEE_ROUTES),
  },
  { path: 'forbidden', component: Forbidden },
  { path: '**', component: NotFound },
];
// employees/employees.routes.ts
export const EMPLOYEE_ROUTES: Routes = [
  { path: '', component: EmployeeList },
  { path: 'new', component: EmployeeForm, canActivate: [roleGuard], data: { roles: ['admin'] } },
  { path: ':id', component: EmployeeDetail },
  { path: ':id/edit', component: EmployeeForm, canActivate: [roleGuard], data: { roles: ['admin'] } },
];

app.routes.tsloginguestGuardemployeesauthGuard + lazy** / forbiddenpublic''EmployeeListnewroleGuard admin:idEmployeeDetail:id/editroleGuard admin

The route tree. The employees section is loaded only after login.

  • withComponentInputBinding() puts :id into an @Input() id of the details and form components.
  • new is listed before :id.
  • The same EmployeeForm is used for add (no id) and edit (with an id).

Route order matters. If :id came before new, the router would match /employees/new with :id = "new" and open the details page instead of the form.

Employee State

EmployeeStore holds all employee data in signals, as in the State Management lesson:

  • Private writable signals: the employee list, the search text and the department.
  • Read-only signals for components: created with asReadonly().
  • Computed signals: filtered, total and activeCount.
  • Methods: load(), setSearch(), setDepartment(), create(), update() and delete().

EmployeeStore with search and filter

import { Component, Injectable, computed, inject, signal } from '@angular/core';

export interface Employee {
  id: number;
  name: string;
  department: string;
  active: boolean;
}

@Injectable({ providedIn: 'root' })
export class EmployeeStore {
  private readonly _employees = signal<Employee[]>([
    { id: 1, name: 'Asha Rao', department: 'Engineering', active: true },
    { id: 2, name: 'Ravi Kumar', department: 'Engineering', active: true },
    { id: 3, name: 'Meena Iyer', department: 'Design', active: true },
    { id: 4, name: 'John Peter', department: 'Sales', active: false },
  ]);
  private readonly _search = signal('');
  private readonly _department = signal('All');

  readonly department = this._department.asReadonly();
  readonly filtered = computed(() => {
    const text = this._search().toLowerCase();
    const department = this._department();
    return this._employees().filter(
      e => (department === 'All' || e.department === department) && e.name.toLowerCase().includes(text),
    );
  });
  readonly total = computed(() => this._employees().length);
  readonly activeCount = computed(() => this._employees().filter(e => e.active).length);

  setSearch(text: string) {
    this._search.set(text);
  }
  setDepartment(department: string) {
    this._department.set(department);
  }
}

@Component({
  selector: 'app-root',
  template: `
    <p>{{ store.activeCount() }} active of {{ store.total() }} employees</p>
    <input placeholder="Search by name" (input)="store.setSearch($any($event.target).value)" />
    <p>
      @for (d of departments; track d) {
        <button (click)="store.setDepartment(d)" [disabled]="d === store.department()">{{ d }}</button>
      }
    </p>
    @for (e of store.filtered(); track e.id) {
      <p>{{ e.name }} ({{ e.department }})</p>
    } @empty {
      <p>No employees match your search.</p>
    }
  `,
})
export class App {
  store = inject(EmployeeStore);
  departments = ['All', 'Engineering', 'Design', 'Sales'];
}

Type a name, click a department, and combine both. The list is a computed signal, so it updates by itself.

After a successful save, the store updates its list with update(), so every page shows the new data without loading the whole list again:

update(employee: Employee) {
  return this.api.update(employee).pipe(
    tap(saved => this._employees.update(list => list.map(e => (e.id === saved.id ? saved : e)))),
  );
}

Employee Forms

EmployeeForm is a reactive form built with FormBuilder:

form = this.fb.nonNullable.group({
  name: ['', [Validators.required, Validators.minLength(2)]],
  email: ['', [Validators.required, Validators.email]],
  department: ['Engineering', Validators.required],
  position: ['', Validators.required],
  salary: [50000, [Validators.required, Validators.min(0)]],
  joinDate: ['', [Validators.required, notInFuture]],
  active: [true],
});
  • nonNullable makes reset() return each control to its first value instead of null.
  • In edit mode, the component loads the employee and fills the form with setValue().
  • On Save, it calls store.create() or store.update() and opens the details page.

Form Validation

The form uses built-in validators and one custom validator, notInFuture, from the Form Validation lesson:

export function notInFuture(control: AbstractControl): ValidationErrors | null {
  if (!control.value) return null;
  const today = new Date().toISOString().slice(0, 10);
  return control.value > today ? { futureDate: true } : null;
}

Messages appear only after the user touched or changed a field. On Save, markAllAsTouched() shows every message at once.

The employee form with validation

import { Component, inject, signal } from '@angular/core';
import { AbstractControl, FormBuilder, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';

export function notInFuture(control: AbstractControl): ValidationErrors | null {
  if (!control.value) return null;
  const today = new Date().toISOString().slice(0, 10);
  return control.value > today ? { futureDate: true } : null;
}

@Component({
  selector: 'app-root',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="save()">
      <p>Name <input formControlName="name" />
        @if (showError('name')) { <span class="error">At least 2 characters.</span> }</p>
      <p>Email <input formControlName="email" />
        @if (showError('email')) { <span class="error">Enter a valid email.</span> }</p>
      <p>Salary <input formControlName="salary" type="number" />
        @if (showError('salary')) { <span class="error">0 or more.</span> }</p>
      <p>Joining date <input formControlName="joinDate" type="date" />
        @if (form.controls.joinDate.hasError('futureDate')) {
          <span class="error">Cannot be in the future.</span>
        } @else if (showError('joinDate')) {
          <span class="error">Required.</span>
        }</p>
      <button type="submit">Save</button>
    </form>
    <p>{{ result() }}</p>
  `,
  styles: `.error { color: #b91c1c; font-size: 13px; }`,
})
export class App {
  private fb = inject(FormBuilder);
  result = signal('');

  form = this.fb.nonNullable.group({
    name: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    salary: [50000, [Validators.required, Validators.min(0)]],
    joinDate: ['2099-01-01', [Validators.required, notInFuture]],
  });

  showError(name: string) {
    const control = this.form.get(name);
    return !!control && control.invalid && (control.touched || control.dirty);
  }

  save() {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      this.result.set('Please fix the marked fields.');
      return;
    }
    this.result.set(`Saved: ${this.form.getRawValue().name}`);
  }
}

The joining date is in the future, so its message is shown from the start. Click Save to see all the other messages, then fill in the fields correctly.

Employee Records

The list and details pages display the employee records:

  • The list shows a table row per employee, with track employee.id, and a link to ['/employees', employee.id].
  • The details page reads the id input, requests GET /api/employees/:id, and shows the record with the currency and date pipes.
  • Delete asks for confirmation with the browser's confirm() dialog, calls store.delete(), and returns to the list.

Records: list, details and delete

import { ApplicationConfig, Component, Input, inject, signal } from '@angular/core';
import {
  HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
  provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { Router, RouterLink, RouterOutlet, Routes, provideRouter, withComponentInputBinding } from '@angular/router';
import { delay, mergeMap, of, throwError, timer } from 'rxjs';

@Component({
  selector: 'app-employee-list',
  imports: [RouterLink],
  template: `
    <h4>Employees</h4>
    @for (e of employees(); track e.id) {
      <p><a [routerLink]="['/employees', e.id]">{{ e.name }}</a> ({{ e.position }})</p>
    }
  `,
})
export class EmployeeList {
  employees = signal<any[]>([]);
  constructor() {
    inject(HttpClient).get<any[]>('/api/employees').subscribe(list => this.employees.set(list));
  }
}

@Component({
  selector: 'app-employee-detail',
  imports: [RouterLink],
  template: `
    <p><a routerLink="/employees">← All employees</a></p>
    @if (employee(); as e) {
      <h4>{{ e.name }}</h4>
      <p>{{ e.position }}, {{ e.department }}, {{ e.active ? 'active' : 'inactive' }}</p>
      <button (click)="remove(e.id)">Delete</button>
    } @else {
      <p>{{ message() }}</p>
    }
  `,
})
export class EmployeeDetail {
  private http = inject(HttpClient);
  private router = inject(Router);
  employee = signal<any>(null);
  message = signal('Loading...');

  @Input() set id(value: string) {
    this.http.get<any>(`/api/employees/${value}`).subscribe({
      next: e => this.employee.set(e),
      error: (err: HttpErrorResponse) => this.message.set(err.error.message),
    });
  }

  remove(id: number) {
    this.http.delete(`/api/employees/${id}`).subscribe(() => this.router.navigate(['/employees']));
  }
}

const routes: Routes = [
  { path: '', redirectTo: 'employees', pathMatch: 'full' },
  { path: 'employees', component: EmployeeList },
  { path: 'employees/:id', component: EmployeeDetail },
];

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withInterceptors([practiceServer])),
  ],
};

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `<router-outlet />`,
})
export class App {}
// ─── Practice server ──────────────────────────────────────────────
// Answers the employees API inside the browser, like in the API Integration lesson.
let employees = [
  { id: 1, name: 'Asha Rao', department: 'Engineering', position: 'Frontend Developer', active: true },
  { id: 2, name: 'Ravi Kumar', department: 'Engineering', position: 'Backend Developer', active: true },
  { id: 3, name: 'Meena Iyer', department: 'Design', position: 'UX Designer', active: true },
  { id: 4, name: 'John Peter', department: 'Sales', position: 'Account Manager', active: false },
];
let serverDown = false;

function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
  if (serverDown) return fail(500, 'Internal Server Error');
  const id = Number(req.url.split('/')[3]);
  if (req.url === '/api/employees' && req.method === 'GET') return reply(employees);
  if (req.method === 'GET') {
    const employee = employees.find(e => e.id === id);
    return employee ? reply(employee) : fail(404, 'Employee not found.');
  }
  if (req.method === 'DELETE') {
    employees = employees.filter(e => e.id !== id);
    return reply(null);
  }
  return fail(404, 'Not Found');
}

function reply(body: unknown) {
  return of(new HttpResponse({ status: 200, body })).pipe(delay(250));
}

function fail(status: number, message: string) {
  return timer(250).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, error: { message } }))));
}

Open an employee, delete them, and see that the list has one employee less. (The practice block deletes without asking, to keep it short.)

API Integration

EmployeeApiService contains every request for employees. It reads the server address from the environment file:

@Injectable({ providedIn: 'root' })
export class EmployeeApiService {
  private http = inject(HttpClient);
  private url = `${environment.apiUrl}/api/employees`;

  getAll() { return this.http.get<Employee[]>(this.url); }
  getOne(id: number) { return this.http.get<Employee>(`${this.url}/${id}`); }
  create(employee: NewEmployee) { return this.http.post<Employee>(this.url, employee); }
  update(employee: Employee) { return this.http.put<Employee>(`${this.url}/${employee.id}`, employee); }
  delete(id: number) { return this.http.delete<void>(`${this.url}/${id}`); }
}
  • NewEmployee is Omit<Employee, 'id'>: a new employee has no id yet. The server creates it.
  • The interceptor adds the token, so the service does not need to know about authentication.

Error Handling

The app handles errors at three levels:

  1. Requests: the store catches HTTP errors and turns the status code into a clear message with messageFor(). The list shows the message with a Try again button.
  2. Forms: server errors such as 409 ("This email is already used.") appear under the form.
  3. Everything else: AppErrorHandler replaces ErrorHandler, writes the error to the console and shows a banner at the top of the app.
export function messageFor(err: HttpErrorResponse, fallback: string) {
  if (err.status === 0) return 'Could not reach the server. Is "npm run api" running?';
  if (err.status === 403) return 'Only administrators can do this.';
  return err.error?.message ?? fallback;
}

Loading and error states with Try again

import { ApplicationConfig, Component, inject, signal } from '@angular/core';
import {
  HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
  provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { delay, finalize, mergeMap, of, throwError, timer } from 'rxjs';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withInterceptors([practiceServer]))],
};

@Component({
  selector: 'app-root',
  template: `
    <label><input type="checkbox" (change)="toggleServer()" /> Server is down</label>
    <button (click)="load()">Reload</button>
    @if (loading()) {
      <p>Loading employees...</p>
    } @else if (error()) {
      <p>⚠ {{ error() }} <button (click)="load()">Try again</button></p>
    } @else {
      <p>{{ count() }} employees loaded.</p>
    }
  `,
})
export class App {
  private http = inject(HttpClient);
  loading = signal(false);
  error = signal('');
  count = signal(0);

  constructor() {
    this.load();
  }

  toggleServer() {
    serverDown = !serverDown;
  }

  load() {
    this.loading.set(true);
    this.error.set('');
    this.http
      .get<any[]>('/api/employees')
      .pipe(finalize(() => this.loading.set(false)))
      .subscribe({
        next: list => this.count.set(list.length),
        error: (err: HttpErrorResponse) => this.error.set(`Could not load the employees (${err.status}).`),
      });
  }
}
// ─── Practice server ──────────────────────────────────────────────
// Answers the employees API inside the browser, like in the API Integration lesson.
let employees = [
  { id: 1, name: 'Asha Rao', department: 'Engineering', position: 'Frontend Developer', active: true },
  { id: 2, name: 'Ravi Kumar', department: 'Engineering', position: 'Backend Developer', active: true },
  { id: 3, name: 'Meena Iyer', department: 'Design', position: 'UX Designer', active: true },
  { id: 4, name: 'John Peter', department: 'Sales', position: 'Account Manager', active: false },
];
let serverDown = false;

function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
  if (serverDown) return fail(500, 'Internal Server Error');
  const id = Number(req.url.split('/')[3]);
  if (req.url === '/api/employees' && req.method === 'GET') return reply(employees);
  if (req.method === 'GET') {
    const employee = employees.find(e => e.id === id);
    return employee ? reply(employee) : fail(404, 'Employee not found.');
  }
  if (req.method === 'DELETE') {
    employees = employees.filter(e => e.id !== id);
    return reply(null);
  }
  return fail(404, 'Not Found');
}

function reply(body: unknown) {
  return of(new HttpResponse({ status: 200, body })).pipe(delay(250));
}

function fail(status: number, message: string) {
  return timer(250).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, error: { message } }))));
}

Tick Server is down, then click Reload. Untick it and click Try again.

Performance Optimization

The project uses the techniques from the Performance Optimization lesson:

  • Lazy loading: the employee section is a separate file, loaded after login with loadChildren. The login page loads faster.
  • OnPush in every component. All data comes from signals and inputs, so no update is missed.
  • Computed signals for the filtered list and the counters, instead of methods in templates.
  • track employee.id in the table, so a reload keeps the existing rows.
  • Store updates after saving, instead of loading the whole list again.

Run npm run build in the downloaded project. The output shows the employee section under Lazy chunk files, separate from the main bundle.

Final Review

Log inJWT storedGuardsauthGuard, roleGuardStore loadsGET /api/employeesRecordslist, add, edit, deleteErrorsmessages + bannerEvery part of the app, from login to error handling.

One user journey through the whole app.

Check your understanding of the whole app with these questions:

  • Where is the employee list stored? In EmployeeStore, in a private signal.
  • Who sends the token? authInterceptor, for every request.
  • What stops a viewer from deleting an employee? The server answers 403. The UI also hides the button and the guard blocks the edit page.
  • What happens when the token expires? The timer calls logout(), and the login page shows "Your session has expired".
  • What happens when the server is down? The list shows a clear message with Try again.

Ideas to extend the project on your own:

  • Add pagination to the employee list.
  • Add a dashboard page with counts per department, using computed signals.
  • Add a "My profile" page for viewers.

Download Project Files

Download Employee Management Project Source Code

Download angular-employee-management.zip

The ZIP file contains the complete project: the Angular app and the Node.js API server, which needs no extra packages.

FolderWhat it contains
server/Login with JWT, and the employee API with role checks
src/app/auth/AuthService, interceptor and guards
src/app/core/The global error handler and the error banner
src/app/shared/PageHeader, StatusBadge and SearchBox
src/app/employees/Model, API service, store, list, details, form, validator and lazy routes
src/app/pages/Login, access denied and not found

Project Setup Instructions

You need Node.js 20.19 or newer.

  1. Unzip the file and open a terminal in the employee-management folder.
  2. Install the packages:

    npm install
  3. Start the API server on http://localhost:3000. Keep this terminal open:

    npm run api
  4. In a second terminal, start the Angular app:

    npm start
  5. Open http://localhost:4200 and log in:

    EmailPasswordRole
    admin@company.comAdmin123!HR admin: view, add, edit, delete
    viewer@company.comViewer123!Viewer: view only

To test automatic logout, start the API with npm run api:short instead. Tokens then expire after 30 seconds.

Common Mistakes

MistakeWhat you seeFix
Components call HttpClient directlyRequest code is repeated, and pages show different dataUse the API service and the store
:id route listed before new/employees/new opens the details page for the id "new"List new before :id
Only hiding buttons for viewersViewers can still change data with direct API callsCheck the role on the server too
Loading the whole list after every saveSlow pages and extra requestsUpdate the store's signal with the saved employee
No markAllAsTouched() on saveAn invalid form shows no messagesCall it when the form is invalid

Check Yourself

Exercises

What to Remember

  • Organize a real app by feature: models, API services, a signal store, components and lazy-loaded routes. Data flows from components to the store, to the API service, to the server.
  • Combine authentication (JWT, interceptor) with authorization in three places: guards, templates and the server.
  • Use reactive forms with built-in and custom validators, handle errors at every level, and keep the app fast with lazy loading, OnPush, computed signals and track.

Next: Cheat Sheet, a short reference of everything in this course.