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:
- See all employees in a table with name, department, role and salary band.
- Search by name, filter by department and sort by name.
- Open an employee detail page at
/employees/:id. - Add a new employee with a validated form.
- 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
- Detail page. Create
EmployeeDetailwithid = input.required<string>()(the app config from the routing lessons useswithComponentInputBinding(); add it again if you started a new project). Show the employee's fields and a "Back to list" link. Handle an unknown id. - Add form. Create
EmployeeFormwith a reactive form: name (required, at least 3 characters), role (required), department (select) and band. On submit callstore.add()and navigate to the list. - Navigation. Add an "Add employee" link to the list page.
- 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/3shows Chen Li;/employees/999shows a not-found message, not a blank page.- The form shows validation messages and does not submit invalid data.
ng buildpasses 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
localStoragelike the Task Manager. - Load the data from an API with
HttpClientand add loading and error states. - Add a delete button with a confirmation step.
Quick check
- Why are search, filter and sort inside one
computed()? - Why does the store return
[...list].sort(...)instead of sortinglist? - What attribute on
thhelps screen readers understand the table?
Last project: an Online Learning Platform.