Angular Project 2: Employee Management Dashboard (Guided)

Angular Tutorial: Beginner to Advanced · 12. Projects and deployment · 6 min read · Updated

This is a guided project. Unlike the Task Manager, you get a working starter and a clear brief, and you build the remaining features yourself. That is how real work feels, and it is the best way to learn.

The brief

Build a dashboard where a manager can:

  1. See all employees in a table with name, department, role and salary band.
  2. Search by name, filter by department and sort by name.
  3. Open an employee detail page at /employees/:id.
  4. Add a new employee with a validated form.
  5. See summary cards: total employees and count per department.

What you will practise

  • Routing with a route parameter
  • computed() for search, filter and sort
  • Reactive forms and a shared store
  • Lazy loading the feature routes

Starter: model and store

// src/app/employees/employee.model.ts
export type Department = 'Engineering' | 'Design' | 'Sales' | 'Support';

export interface Employee {
  id: number;
  name: string;
  department: Department;
  role: string;
  band: 'A' | 'B' | 'C';
}

export const DEPARTMENTS: Department[] = ['Engineering', 'Design', 'Sales', 'Support'];
// src/app/employees/employee-store.ts
import { Injectable, computed, signal } from '@angular/core';
import { DEPARTMENTS, Department, Employee } from './employee.model';

@Injectable({ providedIn: 'root' })
export class EmployeeStore {
  private readonly employees = signal<Employee[]>([
    { id: 1, name: 'Asha Verma', department: 'Engineering', role: 'Frontend Developer', band: 'B' },
    { id: 2, name: 'Ben Carter', department: 'Design', role: 'UX Designer', band: 'B' },
    { id: 3, name: 'Chen Li', department: 'Sales', role: 'Account Manager', band: 'A' },
    { id: 4, name: 'Dina Roy', department: 'Support', role: 'Support Lead', band: 'C' },
    { id: 5, name: 'Eli Brooks', department: 'Engineering', role: 'Backend Developer', band: 'C' },
  ]);

  readonly search = signal('');
  readonly department = signal<Department | 'All'>('All');
  readonly sortAscending = signal(true);

  readonly visible = computed(() => {
    const text = this.search().trim().toLowerCase();
    const dept = this.department();
    const list = this.employees().filter(
      (e) => (dept === 'All' || e.department === dept) && e.name.toLowerCase().includes(text),
    );
    const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name));
    return this.sortAscending() ? sorted : sorted.reverse();
  });

  readonly total = computed(() => this.employees().length);

  readonly perDepartment = computed(() =>
    DEPARTMENTS.map((name) => ({
      name,
      count: this.employees().filter((e) => e.department === name).length,
    })),
  );

  byId(id: number): Employee | undefined {
    return this.employees().find((e) => e.id === id);
  }

  add(data: Omit<Employee, 'id'>): void {
    const id = this.employees().reduce((max, e) => Math.max(max, e.id), 0) + 1;
    this.employees.update((list) => [...list, { id, ...data }]);
  }
}

Starter: list page and routes

// src/app/employees/employee-list.ts
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { EmployeeStore } from './employee-store';
import { DEPARTMENTS, Department } from './employee.model';

@Component({
  selector: 'app-employee-list',
  imports: [RouterLink],
  template: `
    <h2>Employees</h2>

    <section aria-label="Summary" class="cards">
      <div class="card"><strong>{{ store.total() }}</strong><span>Total</span></div>
      @for (d of store.perDepartment(); track d.name) {
        <div class="card"><strong>{{ d.count }}</strong><span>{{ d.name }}</span></div>
      }
    </section>

    <div class="filters">
      <label for="search">Search</label>
      <input id="search" type="search" [value]="store.search()" (input)="onSearch($event)" />

      <label for="dept">Department</label>
      <select id="dept" (change)="onDepartment($event)">
        <option value="All">All</option>
        @for (d of departments; track d) {
          <option [value]="d">{{ d }}</option>
        }
      </select>

      <button type="button" (click)="store.sortAscending.set(!store.sortAscending())">
        Sort: {{ store.sortAscending() ? 'A to Z' : 'Z to A' }}
      </button>
    </div>

    <table>
      <thead>
        <tr><th scope="col">Name</th><th scope="col">Department</th><th scope="col">Role</th><th scope="col">Band</th></tr>
      </thead>
      <tbody>
        @for (e of store.visible(); track e.id) {
          <tr>
            <td><a [routerLink]="['/employees', e.id]">{{ e.name }}</a></td>
            <td>{{ e.department }}</td>
            <td>{{ e.role }}</td>
            <td>{{ e.band }}</td>
          </tr>
        } @empty {
          <tr><td colspan="4">No employees match your filters.</td></tr>
        }
      </tbody>
    </table>
  `,
  styles: `
    .cards { display: flex; gap: .75rem; flex-wrap: wrap; margin: 1rem 0; }
    .card { border: 1px solid #ddd; border-radius: .5rem; padding: .75rem 1rem; display: grid; }
    .filters { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }
    table { border-collapse: collapse; width: 100%; }
    th, td { text-align: left; padding: .5rem; border-bottom: 1px solid #eee; }
  `,
})
export class EmployeeList {
  protected readonly store = inject(EmployeeStore);
  protected readonly departments = DEPARTMENTS;

  protected onSearch(event: Event): void {
    this.store.search.set((event.target as HTMLInputElement).value);
  }

  protected onDepartment(event: Event): void {
    this.store.department.set((event.target as HTMLSelectElement).value as Department | 'All');
  }
}
// src/app/employees/employee.routes.ts
import { Routes } from '@angular/router';
import { EmployeeList } from './employee-list';

export const EMPLOYEE_ROUTES: Routes = [
  { path: '', component: EmployeeList, title: 'Employees' },
  // TODO (your work): { path: 'new', ... } and { path: ':id', ... }
];
// src/app/app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'employees' },
  {
    path: 'employees',
    loadChildren: () => import('./employees/employee.routes').then((m) => m.EMPLOYEE_ROUTES),
  },
  { path: '**', redirectTo: 'employees' },
];
// src/app/app.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  imports: [RouterOutlet],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <h1>Employee Dashboard</h1>
  <router-outlet />
</main>

Run it with ng serve. You should see summary cards, a working search, department filter and sort.

Your tasks

  1. Detail page. Create EmployeeDetail with id = input.required<string>() (the app config from the routing lessons uses withComponentInputBinding(); add it again if you started a new project). Show the employee's fields and a "Back to list" link. Handle an unknown id.
  2. Add form. Create EmployeeForm with a reactive form: name (required, at least 3 characters), role (required), department (select) and band. On submit call store.add() and navigate to the list.
  3. Navigation. Add an "Add employee" link to the list page.
  4. Empty and error states. Make sure a search with no matches shows a friendly message (the starter already does).

Acceptance criteria

  • Typing in the search box updates the table without a button press.
  • Department filter and search work together.
  • /employees/3 shows Chen Li; /employees/999 shows a not-found message, not a blank page.
  • The form shows validation messages and does not submit invalid data.
  • ng build passes and the employee code is in a lazy chunk.
  • You wrote at least three unit tests for EmployeeStore (search, filter, add).

Stretch goals

  • Persist employees in localStorage like the Task Manager.
  • Load the data from an API with HttpClient and add loading and error states.
  • Add a delete button with a confirmation step.

Quick check

  1. Why are search, filter and sort inside one computed()?
  2. Why does the store return [...list].sort(...) instead of sorting list?
  3. What attribute on th helps screen readers understand the table?

Last project: an Online Learning Platform.

Frequently asked questions

I am stuck on the detail page. Where do I look?

Re-read the route parameters lesson. You need the :id route, an input.required<string>(), and store.byId(Number(id)).

Can I use a UI library such as Angular Material?

Yes. It is a good way to speed up the look. Add it after the logic works so you understand what the library does for you.