04 — Frontend Architecture

How the Angular SPA is organized, bootstrapped, routed, themed, localized, and authenticated. Per-feature components are catalogued in 05-frontend-features.

Tech stack & versions

frontend/package.json (key entries):

Package Version Role
@angular/* ^18.2.0 Framework — standalone components, signals, control-flow @if/@for
primeng ^18.0.2 UI component kit
@primeng/themes ^18.0.2 Aura/Material/Lara/Nora preset themes
primeicons ^7.0.0 Icon font (.pi pi-*)
html5-qrcode ^2.3.8 Camera scanner in mobile audit shell
qrcode ^1.5.4 Asset-label QR generation
quill ^2.0.3 Rich-text editor (notification templates, transfer reason)
rxjs ~7.8.0 HTTP + state plumbing
zone.js ~0.14.10 Required by Angular zone change detection
typescript ~5.5.2 dev only
karma + jasmine latest 5.x dev test runner

Build/dev scripts: ng serve (proxy to http://localhost:5xxx configured in proxy.conf.json), ng build, ng test.

Folder layout

frontend/
├── angular.json                    ← Angular CLI workspace config
├── package.json
├── proxy.conf.json                 ← /api → backend
├── tsconfig.{json,app.json,spec.json}
└── src/
    ├── index.html                  ← inline FOUC-killer (paints body bg before SCSS loads)
    ├── main.ts                     ← bootstrapApplication(AppComponent, appConfig)
    ├── styles.scss                 ← global styles + RTL overlay fix
    ├── environments/
    │   ├── environment.ts          ← apiBaseUrl = '/api/v1'
    │   └── environment.prod.ts
    └── app/
        ├── app.component.ts        ← <router-outlet>
        ├── app.config.ts           ← providers
        ├── app.routes.ts           ← lazy route table
        ├── core/                   ← singletons + cross-cutting
        │   ├── guards/             ← authGuard, permissionGuard
        │   ├── interceptors/       ← authInterceptor (refresh-on-401)
        │   ├── services/           ← one per backend resource
        │   ├── i18n/               ← I18nService + pipes + bundled translations
        │   ├── theme/              ← ThemeService (light/dark) + PresetService
        │   ├── pipes/              ← AuditPhotoPipe etc.
        │   └── models/             ← TypeScript interfaces mirroring DTOs
        ├── shared/                 ← reusable components, directives, pipes
        │   ├── components/         ← NotificationBell, GlobalSearch, HierarchyPicker, …
        │   └── directives/         ← LangDirDirective
        ├── layouts/
        │   ├── main-layout/        ← desktop shell (top bar + side nav)
        │   └── mobile-layout/      ← mobile shell (top bar only, simplified menus)
        └── features/               ← one folder per feature; see [05](05-frontend-features)

Bootstrap flow

main.ts
  └─ bootstrapApplication(AppComponent, appConfig)

app.config.ts (providers)
  ├─ provideZoneChangeDetection({ eventCoalescing: true })
  ├─ provideRouter(routes, withComponentInputBinding())
  ├─ provideHttpClient(withInterceptors([authInterceptor]))
  ├─ provideAnimationsAsync()
  ├─ providePrimeNG({ theme: { preset: Aura, options: { darkModeSelector: '.p-dark' } }, ripple: true })
  ├─ MessageService     ← PrimeNG toasts/dialogs
  └─ ConfirmationService

AppComponent template = <router-outlet />

The dark-mode selector matters: darkModeSelector: '.p-dark' tells PrimeNG's CSS to flip variables when <html> carries class="p-dark". ThemeService.apply() adds/removes that class on documentElement.

Routing

app.routes.ts defines two top-level shells:

flowchart TB
  L[/login/] -.no guard.-> Login
  R[/]
  M[/mobile/]

  R -- authGuard --> ML[MainLayoutComponent]
  ML --> children1[Desktop child routes:<br/>dashboard, users, roles, assets,<br/>transfers, audits, maintenance,<br/>reports, settings, profile, ...]
  M -- authGuard --> MoL[MobileLayoutComponent]
  MoL --> children2[Mobile child routes:<br/>mobile-home, assignment/:id,<br/>profile, notifications]

Guard pattern

authGuard (functional CanActivateFn) checks TokenStorageService.isAuthenticated() (presence of an access token in localStorage). No token → redirect to /login.

permissionGuard reads route data:

{ permissions: ['user.read'] }            // ALL required (default)
{ anyPermission: ['user.read', 'user.create', 'user.update'] }   // ANY required

Loads currentUser() lazily on first run (covers deep-link refresh case), then evaluates auth.hasPermission(...) / auth.hasAnyPermission(...). Failure → redirect to /.

Cross-shell redirect

When a Mobile-typed user lands on the desktop shell, MainLayoutComponent.ngOnInit (line 165) bounces them to /mobile. The mobile layout has the inverse — see §Mobile shell. This means mobile users navigating to desktop-only routes get sent home, not 404'd. A consequence: things like /profile and /notifications exist under both shells, so cross-shell navigation needs the right prefix — the notification-bell component checks auth.currentUser()?.userType === 'Mobile' to pick /mobile/notifications vs /notifications.

Full route table

Path Permission(s) Component
/login (none) LoginComponent
/ (auth only) DashboardComponent
/users user.read UserListComponent
/users/:id any of user.read, user.create, user.update UserDetailComponent
/roles role.read RoleListComponent
/roles/:id any of role.read, role.create, role.update RoleDetailComponent
/permissions permission.read PermissionsBrowserComponent
/organizations organization.read OrganizationsComponent
/organizations/tree organization.view-tree OrganizationTreeComponent
/locations location.read LocationsComponent
/locations/tree location.view-tree LocationTreeComponent
/classifications classification.read ClassificationsComponent
/classifications/tree classification.view-tree ClassificationTreeComponent
/vendors vendor.read VendorsComponent
/manufacturers manufacturer.read ManufacturersComponent
/assets asset.read AssetListComponent
/assets/:id any of asset.read, asset.create, asset.update AssetDetailComponent
/transfers asset-transfer.read TransferListComponent
/transfers/:id any of asset-transfer.read, asset-transfer.create TransferDetailComponent
/checkouts check-out.read CheckOutsComponent
/audit-plans audit-plan.read AuditPlansComponent
/audit-plans/new audit-plan.create AuditPlanCreateComponent
/audit-plans/:id any of audit-plan.read, audit-plan.update, audit-plan.assign AuditPlanDetailComponent
/my-audits audit-assignment.read MyAuditsComponent
/audit-assignments/:id audit-assignment.read AssignmentDetailComponent
/pending-reviews audit-result.review PendingReviewsComponent
/audit-results/:id audit-result.read ResultReviewComponent
/maintenance-plans maintenance-plan.read MaintenancePlansComponent
/maintenance-plans/:id maintenance-plan.read MaintenancePlanDetailComponent
/maintenance-requests maintenance-request.read MaintenanceRequestsComponent
/work-orders work-order.read WorkOrdersComponent
/work-orders/:id work-order.read WorkOrderDetailComponent
/reports any of the six per-report keys ReportsHubComponent
/reports/asset-inventory report.asset-inventory.read AssetInventoryReportComponent
/reports/audit-results report.audit-results.read AuditResultsReportComponent
/reports/audit-history report.audit-history.read AuditHistoryReportComponent
/reports/maintenance-history report.maintenance-history.read MaintenanceHistoryReportComponent
/reports/transfer-history report.transfer-history.read TransferHistoryReportComponent
/reports/checkout-activity report.checkout-activity.read CheckoutActivityReportComponent
/audit-log audit-log.read AuditLogComponent
/login-audit login-audit.read LoginAuditComponent
/settings/hierarchy hierarchy-config.read HierarchyConfigComponent
/settings/notification-templates notification-template.read NotificationTemplatesComponent
/settings/languages app-language.manage AppLanguagesComponent
/settings/translations translation.read TranslationsSettingsComponent
/settings/email email-settings.manage EmailProviderSettingsComponent
/profile me.profile.read ProfileComponent
/notifications me.notification.read NotificationsInboxComponent
/preferences me.notification-preference.update NotificationPreferencesComponent
/mobile (auth only) MobileHomeComponent
/mobile/assignment/:id audit-assignment.read MobileAssignmentComponent
/mobile/profile me.profile.read ProfileComponent (shared)
/mobile/notifications me.notification.read NotificationsInboxComponent (shared)
** (any other) (no guard) redirect to /

All feature routes use loadComponent, so each route is a separate JS chunk produced by Angular's lazy loader. Initial bundle stays small.

Authentication

Token storage — TokenStorageService

localStorage keyed by at (access token), rt (refresh token), exp (ISO date string). Methods: getAccessToken, getRefreshToken, getExpiresAt, save(TokenResponse), clear, isAuthenticated.

AuthService

Holds two reactive signals — currentUser and permissions (Set<string>) — plus computed isAuthenticated and authReady (true once both /me and /me/permissions complete).

Public API:

Method Notes
login(LoginRequest) POST /auth/login → save tokens → call loadCurrentUser → emit on authStateChanged Subject. Caller can subscribe(() => router.navigate(...)) and be sure user/permissions are loaded.
refresh() POST /auth/refresh → save new tokens. Clears session on failure.
logout() POST /auth/logout (best-effort), clears local session, emits authStateChanged.next(false)
loadCurrentUser() GET /auth/me, then loadPermissions(), then appSettings.load() (primary/secondary language config), then i18n.refreshOverrides()
loadPermissions() GET /auth/me/permissions → populate the Set
hasPermission(key) / hasAnyPermission(keys) Set lookups
changePassword(req) POST /auth/change-password
decodeToken() Local base64-decode of JWT — used for debugging; never trusted for auth decisions

HTTP interceptor — authInterceptor

core/interceptors/auth.interceptor.ts. For every outgoing request:

  1. Adds Authorization: Bearer <accessToken> if a token exists.
  2. Adds Accept-Language: <currentUiLanguage> so the backend can choose primary/secondary content for emails.

On HttpErrorResponse with status 401:

flowchart LR
  A[401 from API] --> B{is /auth/login or /auth/refresh?}
  B -- yes --> Z[propagate error]
  B -- no --> C{is a refresh<br/>already in flight?}
  C -- yes --> D[wait on refreshSubject<br/>then retry with new token]
  C -- no --> E[isRefreshing = true]
  E --> F[POST /auth/refresh]
  F -- success --> G[refreshSubject.next(newToken)]
  G --> H[retry original request]
  F -- failure --> I[clear tokens, redirect /login]

The refreshSubject BehaviorSubject prevents N parallel 401s from firing N refresh calls — only the first one runs the refresh; the others wait on the Subject and retry once the new token arrives.

Internationalization (i18n)

Bundled translations

core/i18n/translations.ts holds the defaults:

export type Language = 'en' | 'ar' | 'fr' | 'de' | 'it' | 'zh' | 'ja' | 'hi' | 'es' | 'ru' | 'fa';

export const LANGUAGES: readonly LanguageInfo[] = [...];   // 11 languages with name/nativeName/rtl

export const translations: Record<Language, Record<string, string>> = {
  en: { 'app.title': 'Asset Tracking System', ... },
  ar: { 'app.title': 'نظام تتبع الأصول', ... },
  ...9 more
};

Two RTL languages (ar, fa) carry rtl: true in their LanguageInfo row.

I18nService

core/i18n/i18n.service.ts. Holds:

  • _language: Signal<Language> — current UI language (persisted in localStorage["language"], default en)
  • _overrides: Signal<Partial<Record<Language, Record<string, string>>>> — admin-edited overrides fetched from the backend

Public:

Member Notes
language Readonly signal
isRtl Computed — LANGUAGES.find(...).rtl
languages The full LANGUAGES array
setLanguage(lang) Persists choice, applies <html lang> and <html dir>, lazy-fetches admin overrides
t(key) Looks up override → bundled → English fallback → key itself (line 59 translations[target][key] ?? translations.en[key] ?? key)
tIn(key, lang) Same, but for explicit language. Used by bilingual editors that need both languages on screen at once
refreshOverrides() Drops local cache, refetches
patchOverride(lang, key, value) Used by the admin Translations editor after save

Two pipes consume it:

Pipe Use
TranslatePipe ('t') {{ 'asset.create' | t }} for static UI strings. Pure-false so language changes re-render.
LocalizePipe {{ user.fullNamePrimary | localize: user.fullNameSecondary }} for bilingual data fields — picks primary or secondary based on the user's UI language matching AppSettings.SecondaryLanguageCode

RTL handling

Three layers:

  1. <html dir> — set by I18nService.applyDocumentAttributes whenever the language changes. Cascades to all UI.
  2. PrimeNG overlay fix — one CSS rule in styles.scss clears inset-inline-start on .p-overlay so dropdowns don't blow out to viewport width in RTL. PrimeNG 18 quirk.
  3. appLangDir directive — for bilingual data input fields specifically. The UI language might be Arabic but the user is editing the primary (English) field — that input must stay LTR. The directive reads AppSettingsService (which says which configured language is primary vs secondary) and applies dir="rtl" or dir="ltr" per input.
<input pInputText formControlName="namePrimary"   appLangDir="primary"   />
<input pInputText formControlName="nameSecondary" appLangDir="secondary" />

Theming

Light/dark — ThemeService

Persists in localStorage["theme"] (light / dark). Initial value falls back to the OS preference (prefers-color-scheme: dark).

apply(theme) does two things:

  1. Toggles <html class="p-dark"> — PrimeNG variables flip on this selector.
  2. Sets inline background-color / color on <html> and <body> to override the FOUC-killer styles inlined in index.html. Without this, the body stays painted at the original color until a hard reload after switching themes.

Preset — PresetService

PrimeNG has four design presets: Aura (default), Material, Lara, Nora. PresetService.setPreset(key) calls usePreset(presetObject) from @primeng/themes which swaps the active design tokens at runtime. Persisted in localStorage["theme-preset"]. Exposed via the user menu in the top bar (see MainLayoutComponent.presetMenuItems).

Per-component dark overrides

Components that hardcode light-mode colors include :host-context(html.p-dark) { ... } blocks for dark-mode tones. Used by ~12 feature components (search the codebase for host-context(html.p-dark)). One specific caution: do NOT apply background: !important to descendants of html5-qrcode's reader element — its qr-box overlay sits over the video and opaque paint kills the camera stream.

Service layer

One service per backend resource, all in core/services/. Signature pattern:

@Injectable({ providedIn: 'root' })
export class XxxService {
  private readonly http = inject(HttpClient);
  private readonly url = `${environment.apiBaseUrl}/xxx`;

  list(params: Filters): Observable<PagedResult<XxxItem>> { ... }
  get(id: string): Observable<XxxDetail> { ... }
  create(req: CreateXxxRequest): Observable<XxxDetail> { ... }
  update(id: string, req: UpdateXxxRequest): Observable<XxxDetail> { ... }
  delete(id: string): Observable<void> { ... }
  // module-specific methods
}

Full list (25 services):

Service Backing controller
AppSettingsService AppSettingsController
AssetTransfersService AssetTransfersController
AssetsService AssetsController
AuditLogService AuditLogController
AuditsService AuditPlansController + AuditAssignmentsController + AuditResultsController (consolidated)
AuthService AuthController
CheckOutsService CheckOutsController
ClassificationsService ClassificationsController
DocumentsService DocumentsController
EmailSettingsService EmailProviderSettingsController
HierarchyConfigService HierarchyConfigController
HierarchyReferenceService helper that wraps Org/Loc/Class lookups for picker components
LocationsService LocationsController
LoginAuditService LoginAuditController
MaintenanceService MaintenancePlansController + MaintenanceRequestsController + WorkOrdersController
ManufacturersService ManufacturersController
NotificationsService NotificationsController + NotificationPreferencesController
OrganizationsService OrganizationsController
PermissionsService PermissionsController
ReportsService ReportsController
RolesService RolesController
TokenStorageService (localStorage only — no backend call)
TranslationsService TranslationsController
UsersService UsersController
VendorsService VendorsController

HierarchyReferenceService is special — it deduplicates parent/ancestor lookups for the HierarchyPickerComponent so the same chain isn't refetched per dropdown.

Models

core/models/*.ts — TypeScript interfaces mirroring the backend DTOs (AssetListItem, MaintenancePlan, AuditAssignment, etc.). Hand-maintained; not auto-generated. When a backend DTO changes, the matching frontend model must too — type-check fails are visible at ng build time.

Notable hierarchy: AssetListItem includes organizationChain, locationChain, classificationChain (as HierarchyNode[]) for the info-icon tooltip on the asset list; the backend builds these in GetAssetsQueryHandler from a per-page parent walk.

Layout shells

Main (desktop) — MainLayoutComponent

Top bar + collapsible side nav. app/layouts/main-layout/main-layout.component.ts.

Nav groups (filtered by permission per item; an entire group hides if all its items hide):

Group key Items
(no header) Dashboard
nav.group.operations Assets, Transfers, Check-outs
nav.group.audits Audit plans, My audits, Pending reviews
nav.group.maintenance Maintenance plans, Maintenance requests, Work orders
nav.group.reporting Reports (hub — ANY of the six per-report keys)
nav.group.masterData Organizations, Locations, Classifications, Vendors, Manufacturers
nav.group.settings Hierarchy config, Notification templates, Email provider, App languages, Translations
nav.group.administration Users, Roles, Permissions, Audit log, Login audit

Top-bar widgets:

  • <app-global-search> — keyboard-shortcut-driven cross-resource search
  • <app-notification-bell> — polls /notifications/unread-count every 60 s; opens an OverlayPanel on click
  • Theme toggle (sun/moon icon)
  • Preset picker (theme presets dropdown, gated to a separate menu)
  • Language picker (dropdown of 11 languages with native names)
  • User menu: Profile (gated by me.profile.read), Preferences (gated by me.notification-preference.update), Logout (always)

ngOnInit: lazy-loads currentUser if missing; if currentUser.userType === 'Mobile', redirects to /mobile.

Mobile shell — MobileLayoutComponent

app/layouts/mobile-layout/mobile-layout.component.ts. Single top bar (no side nav), simplified user menu. Children routes are restricted to /mobile/* so a mobile user clicking a notification with link /audit-assignments/{id} would dead-end on the desktop shell — see 05 §Mobile for the ongoing client-side rewrite.

ngOnInit: lazy-loads currentUser; if userType !== 'Mobile', redirects to /.

User menu navigates to /mobile/profile and /mobile/notifications (NOT /profile / /notifications) — the desktop-shell versions of those routes have a guard that bounces Mobile users back, causing what looks like a "redirect to base URL" bug. Notification bell uses the same convention via routePrefix based on userType.

Shared components

shared/components/:

Component Purpose
notification-bell Top-bar bell with unread count badge + dropdown of recent notifications. Polls every 60 s. Routes to (prefix)/notifications based on userType.
global-search Top-bar searchbar; ⌘K opens it. Cross-resource fuzzy search (assets, users, transfers, …) — wires multiple service calls and merges results.
hierarchy-picker A cascading dropdown for picking from a hierarchical Org/Location/Classification. Anchored to a Level config and respects IncludeChildren.
hierarchy-tree Read-only tree visualization of an Org/Location/Classification subtree.
import-export-toolbar The 3-button bar (Import / Export / Sample template) that appears on every CRUD list page. Wraps service calls + file pickers.
documents-panel Polymorphic Document/DocumentLink UI. Pass (ownerType, ownerId) and it lists, uploads, and links files.
asset-label One-asset QR label for printing — uses qrcode library to render the QR PNG inline.
asset-label-sheet Multi-asset printable A4 sheet of labels (configurable layouts).

Forms convention

  • Reactive forms (FormBuilder, FormGroup) for any form with conditional logic or validation.
  • Template-driven [(ngModel)] for the simplest cases (search inputs, single dropdowns).
  • Bilingual inputs always pair the primary/secondary fields with the appLangDir directive — never hardcode dir="rtl".
  • Save buttons disabled until form valid; loading state via a local signal.
  • Per-row dialogs (asset detail, plan create) use PrimeNG <p-dialog [modal]="true">.

Error handling

  • ExceptionHandlingMiddleware on the backend returns RFC 7807 problem+json (see 01).
  • Frontend services typically don't transform the error — components subscribe with error: err => this.showError(err).
  • showError(err) pattern: extracts (err as { error?: { detail?: string } }).error?.detail, falls back to a generic message, shows a MessageService.add({ severity: 'error', ... }) toast.
  • Form validation failures from the backend (400 with errors: {prop: msgs[]}) are shown as a flat toast — per-field display is not wired (handlers usually validate client-side first).

Build & dev

Command Effect
npm install Install deps (run from frontend/)
npm start (= ng serve) Dev server at http://localhost:4200, HMR on. proxy.conf.json proxies /apihttp://localhost:5xxx
npm run build (= ng build) Production build to dist/frontend
npm run watch ng build --watch --configuration development
npm test (= ng test) Karma + Jasmine unit tests (limited coverage)

Camera APIs (mobile audit shell) require localhost or HTTPS — the dev server's http://localhost:4200 works; LAN-IP HTTP is silently blocked by the browser.

Where to go next

To learn… See
What each feature folder contains, key signals, and flows 05-frontend-features
The endpoints these services call 07-api-reference
Build + run + deploy + seed config 06-operations