Add Patient โ€” Revamp Plan

Customer feedback analysis & implementation plan, before touching code.
๐Ÿ“… 2026-05-25
๐ŸŽฏ Scope: /lab/patients + /lab/requests/new (inline-add)
๐Ÿงพ 8 requirements ยท 2 layers ยท est. 2 PRs

1 Executive Summary

8 customer requirements break across backend validation/schema and frontend UX. Net effect: tighter patient identification (national ID required & unique, Arabic name mandatory), removed clutter (blood group, Quick Add), and an age-first reading model on barcode + form.

# Requirement Layer Complexity
R1Arabic name mandatoryBE + FELow
R2DOB โ†” Age bidirectional sync (user can enter either, system fills the other)FE onlyMedium
R3Auto-translate Arabic โ†’ English on entryFE onlyHigh (external API)
R4Remove blood group from formFE onlyLow
R5Barcode prints Age instead of DOBFE onlyLow
R6Nationality: Saudi / Non-Saudi selectorBE + FEMedium
R7Remove "Quick Add" buttonFE onlyLow
R8National ID mandatory + uniquenessBE + FEMedium

2 Current State (what the form looks like today)

/lab/patients โ†’ Add Patient (today)
MRN * (auto)
Name (English) *
Name (Arabic)
Date of Birth
Gender *
National ID
Blood Group
Phone
Email
Address
๐Ÿ“ค "Quick Add" button (separate dialog)
/lab/patients โ†’ Add Patient (target)
MRN * (auto)
Name (English) * (auto-translated from Arabic)
Name (Arabic) *
Gender *
Date of Birth
Age (Y / M / D, computed)
Nationality (Saudi / Non-Saudi) *
National ID * (unique)
Blood Group โŒ
Phone
Email
Address
๐Ÿ“ค Quick Add button โŒ
Note about /lab/requests/new (inline Add Patient) The request wizard also has a small "Add Patient" dialog. All eight changes must apply there too โ€” same fields, same validators, same nationality + uniqueness checks. We'll handle them in the same FE PR.

3 Requirements โ€” Detailed Analysis

R1 Arabic name becomes mandatory BE + FE Low

Today name_ar is optional in both BE FormRequest and FE form. After ticket #1624, we moved in the opposite direction (made name_en the required one). Customer wants Arabic specifically.

Decision: require both name_ar AND name_en on patients (clinic policy โ€” Arabic-first market needs Arabic, regulators need English). R3's auto-translate fills English from Arabic, so the user really only types once.

DB schema
No change
BE files
Store + Update FormRequest
FE files
patients comp. + wizard inline form
R2 DOB โ†” Age bidirectional โ€” both fields editable, system keeps them in sync FE only Medium

This is bidirectional, not just a computed display. Two interactive paths must both work:

Path A โ€” User enters DOB โ†’ Age fills

Same as before: pick a date in the calendar, the form shows Age: 42 Y ยท 3 M ยท 18 D live below it.

Path B โ€” User enters Age โ†’ DOB fills

User types in the Age fields (Y / M / D as 3 small numeric inputs, or just Y for the common case "this patient is 50 years old, no birthday on file"). The system computes the DOB back:

  • If only Years entered (e.g. "42"): DOB = today โˆ’ 42 years. Month and day default to today's month/day. Form marks DOB as "approximate" via a subtle ~ badge.
  • If Y + M entered (e.g. "42Y 3M"): DOB = today โˆ’ 42 years โˆ’ 3 months. Day defaults to today.
  • If full Y + M + D entered: exact DOB.

Design โ€” UI layout

Two side-by-side blocks:

Date of Birth
๐Ÿ“… 1983-04-15
Age (enter or auto-fill)
42 Y
3 M
18 D

Loop-prevention rule

DOB โ†’ Age and Age โ†’ DOB updates would loop infinitely if not guarded. Use a single "source-of-truth" signal: track which field the user last edited (lastEditedField: 'dob' | 'age') and only propagate from that direction. The opposite field re-renders read-only-style but stays editable.

What's stored in the DB

Only date_of_birth is persisted. Age is never stored โ€” always computed from DOB at display time. When the user enters age only, we store the calculated DOB. Future re-opens of the patient will show the same age (because today minus the stored DOB = the same delta).

Caveat: if a patient is registered with "age 42" on January 1 (DOB stored as 1983-01-01), then re-opened on December 31, the age displays as "42 Y 11 M 30 D". The original "42 years" reading was approximate โ€” the system honestly shows time elapsed.

DB schema
No change
BE files
None
FE files
patient form + wizard inline-add + 1 small utility for DOBโ†”age conversion
R3 Arabic input โ†’ auto-translate to English FE only High

Customer wants typing Arabic to silently populate the English field. This needs a translation source. Two options:

  1. Transliteration only (fast, free) โ€” convert Arabic letters to Latin equivalent. Example: ุฃุญู…ุฏ โ†’ Ahmed. Library: arabic-translator or custom map. Issue: ู…ุญู…ุฏ โ†’ Mohammed/Muhammad/Mohamed โ€” inconsistent transliterations.
  2. Real translation API (Google Translate, Azure Translator, OpenAI) โ€” accurate but costs ~$0.0001/char and needs an API key.

Decision: use a deterministic transliteration map (option 1) by default. Add an "edit" affordance so the user can override (e.g. type ู…ุญู…ุฏ, get Mohammed, but if they want Mohamed they edit the English field). Persist the user's manual edit โ€” don't re-trigger transliteration if English was edited by hand.

If accuracy is later required, swap the transliterator for a backend endpoint that calls Google Translate (1 line of FE code changes โ€” strategy is isolated in a service).

DB schema
No change
New FE service
ArabicTransliterator
External dependency
None (deterministic map)
R4 Remove "Blood Group" field FE only Low

Today the form has a Blood Group select. Customer wants it removed.

Decision: hide the field on the FE, keep the DB column. Reason: existing patient records (24 in dev, more in prod) may already have blood_group values. Dropping the column would lose data; hiding the input preserves history while removing visual clutter. BE FormRequest still accepts blood_group as nullable (no change needed).

DB schema
Keep blood_group column
BE files
None
FE files
2ร— (full form + inline form)
R5 Print Age (not DOB) on barcode label FE only Low

Barcode labels (50ร—25mm via LisBarcodeLabService) today print: barcode + patient name + gender + DOB + tests + request#. Customer wants the DOB line replaced with computed age.

Decision: replace dob: '1983-04-15' with age: '42Y 3M' (years + months, days are too noisy on a 25mm tall label). One swap in the label builder.

DB schema
No change
BE files
None
FE files
LisBarcodeLabService + callers that build label payload
R6 Nationality: Saudi / Non-Saudi selector BE + FE Medium

DB already has passport_country (VARCHAR(3)) and national_id_type (string). Neither directly captures "is Saudi" โ€” they capture document type. For a clean radio Saudi / Non-Saudi the cleanest model is a new enum column.

Decision: add nationality column to lab_patients. Values: saudi, non_saudi. Nullable for now (existing rows have no value). Required on Create going forward.

Why an enum and not a boolean: future-proof โ€” the clinic may later split "non_saudi" into "GCC", "other_arab", "expat", etc. An enum extends; a boolean doesn't.

Cross-field rule: when nationality = saudi, national_id_type should default to NATIONAL_ID. When non_saudi, default to IQAMA (a sensible default โ€” user can still override).

DB schema
+ nationality column (nullable)
Migration
Add column, no backfill
BE files
Migration + Model + Store/Update Request + Resource + Enum class
FE files
Form + radio + model
R7 Remove "Quick Add" button FE only Low

Today the patient list page has a "Quick Add" button that opens a stripped-down dialog with only name fields (no DOB / national ID / nothing else). Once we make national_id mandatory and add nationality, the "quick" path makes no sense โ€” every patient needs the full data.

Decision: delete the Quick Add button, the quickAddForm, the quickAddVisible signal, the dialog template, and the associated translation keys. Pure deletion โ€” no replacement needed because the main "Add" button does exactly what Quick Add did, just with required fields.

DB schema
No change
BE files
None
FE files
patients component + i18n cleanup
R8 National ID mandatory + unique BE + FE Medium

Today national_id is nullable + string. DB has a regular index (MUL), not unique. Customer wants both: required + unique.

Two complications to plan for:

  1. Existing data may have duplicates. Before adding a unique index we must check prod and dev DBs for collisions. If duplicates exist, the migration MUST clean them first (or fail safely). Plan: write the migration to detect duplicates โ†’ if any, abort with a clear error listing which patient IDs share the same national_id; the lab cleans them manually, then re-runs.
  2. Existing patients with NULL national_id โ€” backfill not feasible (we don't know their IDs). Migration must allow NULL but reject duplicate non-null values. MySQL UNIQUE indexes already do this (NULLs are not considered duplicates).

Decision: migration adds UNIQUE (company_id, national_id) index (scoped per company since the system is multi-tenant). BE FormRequest enforces required + unique:lab_patients,national_id,NULL,id,company_id,. FE catches the 422 with field error and shows a friendly message.

DB schema
Add UNIQUE (company_id, national_id)
Migration risk
Pre-check for collisions; abort if found
BE files
Migration + Store/Update FormRequest
FE files
Validator + 422 error toast

4 Backend Plan

3 ticket-sized changes. All in the LIS module. One feature ticket bundling them all (Ahmed reviews + tests).

๐Ÿ”ง BE-1 ยท Migration: add nationality + unique national_id

  1. New migration add_nationality_and_unique_national_id_to_lab_patients:
    • Add column nationality ENUM('saudi','non_saudi') NULL AFTER national_id_type
    • Pre-check: SELECT national_id, COUNT(*) FROM lab_patients WHERE national_id IS NOT NULL GROUP BY company_id, national_id HAVING COUNT(*) > 1 โ€” if rows found, throw with the list of conflicting MRNs so the lab cleans manually
    • Add UNIQUE INDEX lab_patients_company_national_id_unique (company_id, national_id)
    • down() drops both
  2. New Enum class Modules\LIS\Enums\Nationality with cases Saudi = 'saudi', NonSaudi = 'non_saudi' + a label() method for ar/en translations
  3. Update LabPatient model: add 'nationality' to $fillable + cast to the Enum

๐Ÿ”ง BE-2 ยท Store/Update FormRequest rules

  1. StoreLabPatientRequest validation changes:
    • name_ar: 'nullable' โ†’ 'required'
    • name_en: already 'required' from LIS-131 โ€” no change
    • national_id: 'nullable' โ†’ 'required' + add Rule::unique('lab_patients', 'national_id')->where('company_id', auth()->user()->company_id)
    • nationality: ['required', Rule::enum(Nationality::class)]
  2. UpdateLabPatientRequest: mirror but with ->ignore($this->route('patient')) on the unique rule so editing a patient doesn't conflict with itself
  3. Custom validation messages so the duplicate error reads "ู‡ุฐุง ุงู„ุฑู‚ู… ุงู„ู‚ูˆู…ูŠ ู…ุณุฌู„ ู„ู…ุฑูŠุถ ุขุฎุฑ" / "This national ID is already registered for another patient"

๐Ÿ”ง BE-3 ยท Resource + tests

  1. LabPatientResource::toArray(): expose nationality (with value/label object pattern matching other enum fields like gender)
  2. Update PHPUnit feature tests: LabPatientApiTest โ€” new test cases for "fails without national_id", "fails with duplicate national_id within company", "passes with same national_id across different companies", "fails without nationality"
  3. Test the cross-company isolation explicitly โ€” this is multi-tenant safety

5 Frontend Plan

8 changes across 2 components (patient list page + request wizard inline-add). One PR on your repo, merged after BE PR lands.

๐ŸŽจ FE-1 ยท Patient model + service tweaks

  1. Add nationality?: 'saudi' | 'non_saudi' to LisPatient interface
  2. Add to CreateLisPatient as required

๐ŸŽจ FE-2 ยท Arabic name transliterator (NOT a translator)

Critical clarification This is name transliteration โ€” phonetic spelling of a name in Latin letters โ€” not translation of meaning.

Arabic inputโœ… Correct (transliteration)โŒ Wrong (translation)
ู…ุญู…ุฏMohammedPraised
ุนุจุฏุงู„ู„ู‡AbdullahServant of God
ูุงุทู…ุฉFatimaOne who weans
ู†ูˆุฑNourLight
ุฃู…ู„AmalHope
The customer's words: "ุชุฑุฌู…ุฉ ุงุณู… ู…ุด ู…ุนู†ู‰" โ†’ "translation of the name, not the meaning."
  1. New service src/app/core/services/arabic-name-transliterator.service.ts (note: "name-transliterator" โ€” explicit about scope)
  2. Two-layer strategy (the dictionary catches the common cases, the letter map catches the rest):
    • Layer 1 โ€” Common-name dictionary (โ‰ˆ300 entries): the top Arabic first names + family names hard-coded with their canonical English spelling. e.g. { 'ู…ุญู…ุฏ': 'Mohammed', 'ุฃุญู…ุฏ': 'Ahmed', 'ุนุจุฏุงู„ู„ู‡': 'Abdullah', 'ุนู„ูŠ': 'Ali', 'ูุงุทู…ุฉ': 'Fatima', 'ุนู…ุฑ': 'Omar', 'ุญุณู†': 'Hassan', 'ุญุณูŠู†': 'Hussein', 'ูŠูˆุณู': 'Yousef', 'ุฅุจุฑุงู‡ูŠู…': 'Ibrahim', 'ุงู„ุดู…ุฑูŠ': 'Al-Shamri', ... }. Why this matters: ู…ุญู…ุฏ transliterates "letter-by-letter" as Mhmd, which nobody writes. Customer's bank, ID, and hospital records all say "Mohammed" โ€” we must match.
    • Layer 2 โ€” Letter-by-letter fallback: for names not in the dictionary, walk the input applying a phonetic map (ุฃโ†’A, ุจโ†’B, ุชโ†’T, ุฌโ†’J, ุญโ†’H, ุฎโ†’Kh, ุฐโ†’Dh, ุดโ†’Sh, ุตโ†’S, ุถโ†’D, ุทโ†’T, ุธโ†’Z, ุนโ†’', ุบโ†’Gh, ู‚โ†’Q, ูƒโ†’K, ู„โ†’L, ู…โ†’M, ู†โ†’N, ู‡โ†’H, ูˆโ†’W, ูŠโ†’Y). Vowels are tricky (Arabic doesn't write short vowels) โ€” default to inserting a between consonants if no diacritic is present.
  3. For multi-word names like ู…ุญู…ุฏ ุจู† ุนุจุฏุงู„ู„ู‡ ุงู„ุดู…ุฑูŠ:
    • Split on whitespace
    • Look up each part in the dictionary, fallback to letter map for unknown parts
    • Output: Mohammed bin Abdullah Al-Shamri (joined with single spaces)
  4. Public API:
    transliterateName(arabic: string): string
      // 'ู…ุญู…ุฏ ุนู„ูŠ'           โ†’ 'Mohammed Ali'
      // 'ูุงุทู…ุฉ ุงู„ุดู…ุฑูŠ'        โ†’ 'Fatima Al-Shamri'
      // 'ุณู…ูŠุฑ'                โ†’ 'Samir'   (dictionary hit)
      // 'ูƒู„ุงูƒูˆุด'              โ†’ 'Klakwsh' (letter-by-letter โ€” name not common)
  5. Usage in the form:
    • Watch name_ar control's valueChanges (debounced 300ms)
    • If name_en is empty OR was last set by the transliterator (track via a private flag), call transliterateName(arabic) and patch name_en
    • If the user manually edits name_en, set userEditedEn = true and stop auto-syncing โ€” they own that field from then on
    • A small "โ†ป Re-translate" button next to name_en resets userEditedEn = false and re-runs the transliterator
  6. Dictionary source: I'll seed the 300 entries from a public list of common Arab names (Saudi statistics ministry publishes top-name lists). The dictionary lives in the service file as a TypeScript Record<string, string> โ€” easy to expand by hand later.
If we later need higher accuracy Swap the service implementation for one that calls a backend endpoint that proxies Google Translate or OpenAI. Public API stays the same (transliterateName(string): string), so no callers change. We can decide later โ€” start with the offline dictionary + letter map, which costs nothing and handles 90%+ of Saudi names.

๐ŸŽจ FE-3 ยท LisPatientsComponent rewrite

  1. Remove: quickAddVisible, quickAddForm, openQuickAdd(), saveQuickAdd(), the entire Quick Add dialog block in the template, "QUICK_ADD" translation keys (ar + en)
  2. Form changes:
    • name_ar: add Validators.required
    • national_id: add Validators.required
    • nationality: new control ['', Validators.required]
    • blood_group: remove control + UI block (delete the <p-select> for it)
  3. Age field โ€” bidirectional with DOB:
    • New utility computeAge(dob: Date): {y,m,d} + computeDobFromAge({y,m,d}): Date
    • 3 separate small numeric inputs for Y / M / D (M and D optional)
    • Track lastEditedField: 'dob' | 'age' to prevent the dobโ†”age sync from looping
    • When user enters only Y (e.g. "42"), compute DOB = today โˆ’ 42y. Show a small ~ badge next to the DOB to flag it as "approximate"
    • Form submits only date_of_birth โ€” age is never sent to the BE
  4. Arabic typing watcher: subscribe to name_ar valueChanges. If name_en is empty (or was last set by the transliterator), populate it via the new service. Track a name_en_was_user_edited flag to avoid overwriting manual edits
  5. Nationality cross-default: when nationality changes, set national_id_type default (NATIONAL_ID vs IQAMA) but don't lock it
  6. 422 handling: catch the duplicate-national_id error and show "ู‡ุฐุง ุงู„ุฑู‚ู… ุงู„ู‚ูˆู…ูŠ ู…ุณุฌู„ ู„ู…ุฑูŠุถ ุขุฎุฑ"

๐ŸŽจ FE-4 ยท Request wizard inline-add โ€” mirror all the same changes

  1. File: src/app/features/lis/request-wizard-v2/request-wizard-v2.component.ts (newPatientForm block, currently line 549+)
  2. Apply identical changes: name_ar required, national_id required, nationality required, remove blood_group, age display, transliterator hook, 422 handling
  3. Avoid duplication: factor the patient form into a reusable component if practical (judgment call โ€” only if both forms become >100 lines each)

๐ŸŽจ FE-5 ยท Barcode label โ€” print age instead of DOB

  1. File: src/app/core/services/lis-barcode-label.service.ts
  2. BarcodeLabel interface: rename dob โ†’ age (or add age and deprecate dob)
  3. Callers (samples page, patient page) compute age from DOB and pass it in
  4. Label PDF builder: print 42Y 3M instead of 1983-04-15

๐ŸŽจ FE-6 ยท i18n keys

  1. Add: PATIENTS.NATIONALITY, PATIENTS.SAUDI, PATIENTS.NON_SAUDI, PATIENTS.AGE_FORMAT ({{y}}Y ยท {{m}}M ยท {{d}}D), PATIENTS.DUPLICATE_NATIONAL_ID
  2. Remove: PATIENTS.QUICK_ADD, PATIENTS.BLOOD_GROUP (if unused after this change)
  3. Both ar and en JSON files

6 Tickets & PR plan (matches our workflow)

BE-#
LIS: Add Patient revamp โ€” nationality + national_id unique + name_ar required
All three backend changes (migration + FormRequest + Resource + tests) in one ticket. Assignee = whoever codes it (you/Hazem). Acceptance: 4 new test cases pass, migration handles duplicate detection cleanly, no other module touched.
BEMedium
PR-#
Review PR #N โ€” LIS Add Patient revamp (BE)
PR-review ticket โ€” assigned to Ahmed. Contains the GitHub PR link, the feature ticket link, the migration command, and the smoke-test results. Created after the PR opens.
BE
FE
FE: Add Patient form revamp (parallel commit, gated on BE merge)
6 FE sub-tasks bundled in one PR on hazemhamdytaha/moon-erp-angular. Frontend doesn't go through Ahmed (own repo) โ€” opened as a branch + self-merge after BE lands on main.
FE

7 Execution timeline

1
You approve this plan (now)
Confirm scope, transliterator approach (deterministic map vs Google API), and nationality enum vs boolean.
2
Create BE feature ticket on support portal
Assignee: Hazem (id=12). Full spec from ยง4 above pasted into description.
~5 min
3
Implement BE on feat/lis-patient-revamp branch
Migration with duplicate pre-check, FormRequest changes, model, enum, resource, tests. Smoke-test on dev DB (already has 24 patients โ€” perfect for testing the duplicate detection).
~45 min
4
Push branch โ†’ open PR โ†’ create PR-review ticket
PR body with linked tickets section + test plan. PR-review ticket โ†’ Ahmed (id=11).
~10 min
5
While waiting on Ahmed โ€” start FE on feat/lis-patient-form-revamp
FE changes don't break anything until BE lands. Can be developed in parallel. Build the transliterator service, refactor the form, update barcode service. Test locally against dev backend (which has BE changes via cherry-pick).
~90 min
6
Ahmed merges BE PR โ†’ sync dev clone โ†’ deploy FE
git pull + artisan migrate + artisan optimize + composer dump-autoload --classmap-authoritative on dev. Then merge FE branch to main and rebuild Angular bundle.
~15 min
Total estimate ~2.5 hours of active work, plus Ahmed's review turnaround. The BE PR is small (3 files) and the FE PR is mostly mechanical (delete Quick Add, change validators, add transliterator). No risky pieces beyond the migration's duplicate detection.

8 Decisions needed from you before we start

QuestionDefault (if you say "go with defaults")Your call
R3 โ€” Translation source? Deterministic transliteration map (free, instant, offline). User can override the English field manually. Map / Google API / Backend service
R6 โ€” Nationality model? New nationality enum column (extensible to GCC/expat later). Enum / boolean / reuse passport_country
R8 โ€” Multi-tenant uniqueness scope? UNIQUE per company (same national_id allowed across different companies โ€” they're separate tenants). Per company / globally unique
R8 โ€” Existing patient rows without national_id? Leave NULL. MySQL unique allows multiple NULLs. New patients require non-null. Existing users gain a "Complete missing IDs" alert on the dashboard (future enhancement, not this ticket). Backfill / leave NULL / block list view until populated
R8 โ€” Behavior when migration finds existing duplicates? Abort with a list of conflicting patient IDs. Lab merges/cleans, then re-runs. Safer than silent renumbering. Abort / pick latest / pick lowest MRN