06 — Localization & RTL
How the system presents itself in different languages, how bilingual data is captured and rendered, how right-to-left layout works for Arabic and Persian, and how admins customize the translations.
Two layers of localization
The system has two distinct localization concerns that often get confused. Keeping them straight makes everything else clearer.
| Layer | What it is | Example | Driven by |
|---|---|---|---|
| UI translations | The labels on buttons, headings, menus, columns, error messages — the words baked into the interface itself | "Save" / "حفظ", "Asset code" / "رمز الأصل" |
Bundled translation file (translations.ts) + admin overrides |
| Bilingual data | The content users enter — names, descriptions, addresses, notes — captured in pairs (primary + secondary) | Asset NamePrimary = "Conference room TV", NameSecondary = "تلفاز قاعة الاجتماعات" |
Per-record fields in the database |
UI translations make the application speak a language. Bilingual data lets the content speak two languages. Both work together so a user picking Arabic sees "حفظ" on the Save button and the Arabic name of an asset.
Supported UI languages
11 languages ship with bundled translations:
| Code | Name | Native name | Direction |
|---|---|---|---|
en |
English | English | LTR |
ar |
Arabic | العربية | RTL |
fr |
French | Français | LTR |
de |
German | Deutsch | LTR |
it |
Italian | Italiano | LTR |
zh |
Chinese | 中文 | LTR |
ja |
Japanese | 日本語 | LTR |
hi |
Hindi | हिन्दी | LTR |
es |
Spanish | Español | LTR |
ru |
Russian | Русский | LTR |
fa |
Persian (Farsi) | فارسی | RTL |
English and Arabic are the fully translated set — every UI label has a translation. The other nine ship with a partial baseline: anything missing falls back to English. As a deployment matures, an admin (with translation.update) can fill in the gaps via the Translations editor.
How a user picks their language
Every user can change their UI language from any page using the language picker in the top bar (mobile shell: in the user menu). The picker is a dropdown of all 11 languages with their native names. Picking one:
- Reloads every visible label to that language (no page refresh — Angular's reactive signals push the change).
- Sets
html lang="<code>"andhtml dir="rtl"(for Arabic and Persian) ordir="ltr"(everything else). - Persists the choice in the user's browser. The next visit uses the same language.
- Lazy-fetches any admin-edited translation overrides for that language.
The picker is also visible on the login page itself, so non-English users can switch to their language before logging in.
flowchart LR A[User clicks language] --> B[Apply html lang + dir] B --> C[Update bundled translation map] B --> D[Lazy-fetch admin overrides] C --> E[Every TranslatePipe and<br/>LocalizePipe re-renders] D --> E E --> F[Persist choice in browser]
The user's preferred language is also saved to their profile (
PreferredLanguage). Notifications (in-app + email) render in the user's preferred language. So if Sara works in Arabic, her email about a transfer arrives with Arabic subject + Arabic body.
Right-to-left (RTL) behavior
When the active UI language is Arabic or Persian, the entire interface mirrors:
- Text alignment flips — English page reads left-to-right, Arabic flows right-to-left.
- Side nav moves to the right side of the screen.
- Form labels sit to the right of inputs (or above, depending on the layout).
- Icons in buttons swap sides — a "back" arrow that pointed left now points right (because "back" in RTL is towards the right).
- Tables still flow visually the same (cells next to each other), but the column order reverses so the "first" column lands on the right.
- Dropdowns and overlays open to the correct side — a one-line CSS adjustment in
styles.scsscorrects a known PrimeNG quirk where dropdowns in RTL would otherwise blow out to the viewport width.
The user doesn't toggle RTL — it's automatic when the language is RTL.
flowchart TB
subgraph "LTR (English / French / etc.)"
L1[Side nav: LEFT]
L2[Page content: middle/right]
L3[Tables: columns left → right]
end
subgraph "RTL (Arabic / Persian)"
R1[Side nav: RIGHT]
R2[Page content: middle/left]
R3[Tables: columns right ← left]
end
Bilingual data entry
When a user creates or edits a record (an asset, organization, role, notification template, …), most "name" and "description" fields appear as paired inputs — Primary above, Secondary below.
┌──────────────────────────────────────┐
│ Name (Primary) [English LTR ]│
│ Conference Room TV │
├──────────────────────────────────────┤
│ Name (Secondary) [Arabic RTL ]│
│ تلفاز قاعة الاجتماعات │
└──────────────────────────────────────┘
Each input automatically picks its own text direction based on which language the content is. So even if the active UI language is Arabic (whole interface is RTL), the Primary input still flows left-to-right (because Primary = English), while the Secondary input flows right-to-left.
This matters because text direction lives at the data layer, not the UI layer. A user editing in Arabic still expects the English Primary field to type left-to-right; the Arabic Secondary field on the same form types right-to-left. Each input adapts.
The "Primary" and "Secondary" abstraction
The system never hardcodes "Arabic" as one of the languages. Instead it uses Primary and Secondary as labels — and the admin maps them to actual languages.
For a typical Middle East deployment:
- Primary = English
- Secondary = Arabic
For a Spain-Mexico deployment:
- Primary = Spanish
- Secondary = English
For a Russia-Israel deployment:
- Primary = Russian
- Secondary = Hebrew (if added to the language set; not bundled today)
The mapping is set at /settings/languages and applies system-wide. Once set, every "Primary" / "Secondary" label across every form refers to the configured languages.
What's required vs. optional
Bilingual fields require at least one of the two values. The Primary is typically required; the Secondary is optional but encouraged.
If a user enters only Primary, the Secondary stays empty. The display logic falls back to Primary when Secondary is missing — see the next section.
How bilingual data renders
The Localize pipe ({{ namePrimary | localize: nameSecondary }}) decides which value to show, per render:
| User's UI language | Secondary value | Result shown |
|---|---|---|
| Same as Secondary language code | Non-empty | Secondary value |
| Same as Secondary language code | Empty / null | Primary value (fallback) |
| Different from Secondary | (any) | Primary value |
In words: "if the user is browsing in the secondary language and we have content for it, show the secondary; otherwise always show the primary."
A few examples assuming Primary = English, Secondary = Arabic:
| User's lang | NamePrimary | NameSecondary | Renders |
|---|---|---|---|
en |
"Laptop" | "حاسوب محمول" | "Laptop" |
ar |
"Laptop" | "حاسوب محمول" | "حاسوب محمول" |
ar |
"Laptop" | (empty) | "Laptop" (Primary fallback) |
fr |
"Laptop" | "حاسوب محمول" | "Laptop" (French isn't Secondary, so Primary) |
en |
(empty) | "حاسوب محمول" | "حاسوب محمول" (final fallback to Secondary if Primary is empty) |
Every list view, detail page, and dropdown in the system goes through this rule. The user's UI language passively chooses what they see.
Notification rendering
Notifications (in-app + email) follow the same Primary / Secondary model per template. Each notification template has:
SubjectPrimary+SubjectSecondaryBodyPrimary+BodySecondary
When the system sends a notification:
- Look up the recipient's
PreferredLanguage. - If it matches the
SecondaryLanguageCodesetting AND the secondary version of the template is non-empty, render Secondary. - Otherwise render Primary (with Primary-empty fallback to Secondary).
So a Transfer Approver who set their language to Arabic gets the Arabic version of the "Transfer pending your approval" email. An English-speaking colleague gets the English version. Both come from the same template.
Merge fields ({{ transfer.code }}, {{ user.fullName }}, etc.) work in both versions. The system substitutes them after picking which language to render.
Admin-edited translations
The bundled translation file ships with ~1000 keys for English and Arabic, plus partial coverage of the other nine languages. An admin can override any translation without re-deploying the application.
The Translations editor (/settings/translations)
Page layout:
- Tabs per language
- Search box to find keys (search matches both key and value)
- Editable grid: key (read-only) | default value (read-only) | override value (editable)
The editor shows every key + every language. The "default" column is the bundled value from the source code — what the user would see without any override. The "override" column is the admin's customization (empty = no override = use bundled).
┌────────────────────────────────────────────────────────────────────┐
│ [Search: ____] Language: [English ▾] [Arabic ▾] [French ▾] ... │
├──────────────────────┬──────────────────────────┬──────────────────┤
│ Key │ Default │ Override │
├──────────────────────┼──────────────────────────┼──────────────────┤
│ asset.create │ Create asset │ ____ │
│ asset.delete │ Delete asset │ Remove asset │
│ asset.code │ Code │ ____ │
│ ... │ │ │
└──────────────────────┴──────────────────────────┴──────────────────┘
Save / Reset
- Save an override value → the next page render uses it (live, no rebuild).
- Reset a row → drops the override; the bundled default is back in effect.
When to use overrides
Common scenarios:
- Brand voice — the bundled default says "Asset", your team always says "Equipment". Override
asset.code→ "Equipment code",asset.create→ "Add equipment", etc. - Translation polish — an internal Arabic phrase reads stiffer than your team's preferred wording. Override.
- Adding a missing language — the bundled French file has 60% coverage; your team needs 100%. Override the missing keys one by one. (Or better: file a PR upstream with the additions.)
- Domain terminology — laboratory-asset deployments call them "instruments", not "assets". Override.
Active translations endpoint
Authenticated callers fetch the merged map (bundled defaults + their language's overrides) on language switch. So the admin's edits propagate to every signed-in user the next time they switch languages — no global cache to bust.
Export
The admin can export the entire override grid to Excel for review or version control.
Search and filter behavior
When a user searches for "laptop" with English as their UI language:
- The search runs against both
*Primaryand*Secondarycolumns in the database. - Results match if either column contains the term.
So an asset whose Primary is "Laptop" and Secondary is "حاسوب محمول" matches whether the user types "laptop" or "حاسوب".
This is intentional. It means a multilingual deployment doesn't need users to remember which language a record was originally entered in.
Asymmetric data — when only one language is captured
Real organizations never reach 100% bilingual coverage. Common cases:
- Imported records — a bulk import from an English-only spreadsheet leaves every Secondary empty.
- Newer assets — an admin who hasn't gotten around to filling Arabic for the latest 50 assets yet.
- Foreign vendors — a vendor name is "Acme Corp" in both languages because the brand is the same.
The system handles all of these gracefully via the fallback chain. Users browsing Arabic see English on those records (because Arabic is missing). Search still works (it hits both columns). No errors, no broken UI.
The cost is that users browsing in their language sometimes see content in the other language — which is a feature, not a bug. The alternative would be hiding records altogether, which is worse.
Date, number, and currency formatting
The system doesn't currently localize numbers, dates, or currencies based on the user's language. Some specifics:
- Dates display in a system-default format (
MMM d, yandMMM d, y, h:mm:ss a) — Western numerals, English month abbreviations. - Numbers use Western numerals (1, 2, 3 — not Eastern Arabic ١, ٢, ٣).
- Currencies are stored as a 3-letter code (USD, EUR, AED) per record but rendered with the same code, no symbol substitution.
Adding locale-aware formatting (Eastern Arabic numerals, Hijri dates, currency symbols by locale) would be a future enhancement. For now, every user sees the same numeric/date/currency format regardless of UI language.
Best practices for multilingual deployments
Pick Primary and Secondary deliberately
The Primary language is what shows when no Secondary is set, when Secondary is empty, or when the user's language is anything other than the configured Secondary.
Pick the language with the broadest internal use as Primary. For a Saudi or UAE deployment, English is usually Primary even though most users prefer Arabic — because it's the language with the broadest stakeholder reach (vendors, manufacturers, expat staff, exported reports).
Train data entry to fill both fields
Without enforcement, users tend to fill only Primary and leave Secondary empty. The cost shows up later when an Arabic-speaking executive reviews a report and sees half-English content.
Two ways to address:
- Process — make a "must include both" rule for new records. The system has no field-level enforcement of "both required", but a UI quick check (NamePrimary present + NameSecondary present) is easy to enforce in custom validation rules.
- Backfill — periodically run an Excel export, identify records with empty Secondary, fill from a translator, re-import.
Keep override edits documented
The Translations editor doesn't track why an admin overrode a key. Two months later, someone wonders why "asset.create" says "Add equipment" instead of "Create asset" — and the original admin doesn't remember.
Maintain a separate doc (a Confluence page, a README) listing every override and the reason. Or leverage the audit log — overrides are tracked there per the standard auditable-entity rules.
Avoid breaking translations during upgrades
When a new release adds a translation key (say, asset.export.success), the bundled translations get the new key. Existing admin overrides don't conflict — they just don't cover the new key. So the new key uses the bundled default until an admin overrides it.
Going the other way is trickier — a release that removes a key leaves orphaned overrides in the database. The system doesn't error; it just stops using them. Periodic cleanup of orphaned override rows is wise but not currently automated.
Quick reference
| What | Where |
|---|---|
| Pick UI language (per user) | Top bar dropdown / mobile-shell user menu |
| Set Primary + Secondary content languages | /settings/languages (admin) |
| Override a UI label | /settings/translations (admin) |
| Set hierarchy level labels in two languages | /settings/hierarchy (admin) |
| Set notification subject + body in two languages | /settings/notification-templates (admin) |
Where to go next
| To learn… | See |
|---|---|
| What each settings screen looks like | 04 §Settings |
| How notifications are constructed and sent | 03 §Notification flow |
| Which roles can manage languages and translations | 02 §Settings |