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:
- Adds
Authorization: Bearer <accessToken>if a token exists. - 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 inlocalStorage["language"], defaulten)_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:
<html dir>— set byI18nService.applyDocumentAttributeswhenever the language changes. Cascades to all UI.- PrimeNG overlay fix — one CSS rule in
styles.scssclearsinset-inline-starton.p-overlayso dropdowns don't blow out to viewport width in RTL. PrimeNG 18 quirk. appLangDirdirective — 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 readsAppSettingsService(which says which configured language is primary vs secondary) and appliesdir="rtl"ordir="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:
- Toggles
<html class="p-dark">— PrimeNG variables flip on this selector. - Sets inline
background-color/coloron<html>and<body>to override the FOUC-killer styles inlined inindex.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-countevery 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 byme.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
appLangDirdirective — never hardcodedir="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
ExceptionHandlingMiddlewareon the backend returns RFC 7807problem+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 aMessageService.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 /api → http://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 |