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 |
|---|---|---|---|
| R1 | Arabic name mandatory | BE + FE | Low |
| R2 | DOB โ Age bidirectional sync (user can enter either, system fills the other) | FE only | Medium |
| R3 | Auto-translate Arabic โ English on entry | FE only | High (external API) |
| R4 | Remove blood group from form | FE only | Low |
| R5 | Barcode prints Age instead of DOB | FE only | Low |
| R6 | Nationality: Saudi / Non-Saudi selector | BE + FE | Medium |
| R7 | Remove "Quick Add" button | FE only | Low |
| R8 | National ID mandatory + uniqueness | BE + FE | Medium |
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.
This is bidirectional, not just a computed display. Two interactive paths must both work:
Same as before: pick a date in the calendar, the form shows Age: 42 Y ยท 3 M ยท 18 D live below it.
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:
DOB = today โ 42 years. Month and day default to today's month/day. Form marks DOB as "approximate" via a subtle ~ badge.DOB = today โ 42 years โ 3 months. Day defaults to today.Two side-by-side blocks:
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.
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.
Customer wants typing Arabic to silently populate the English field. This needs a translation source. Two options:
ุฃุญู
ุฏ โ Ahmed. Library: arabic-translator or custom map. Issue: ู
ุญู
ุฏ โ Mohammed/Muhammad/Mohamed โ inconsistent transliterations.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).
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).
blood_group columnBarcode 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 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).
nationality column (nullable)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.
Today national_id is nullable + string. DB has a regular index (MUL), not unique. Customer wants both: required + unique.
Two complications to plan for:
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.
3 ticket-sized changes. All in the LIS module. One feature ticket bundling them all (Ahmed reviews + tests).
nationality + unique national_idadd_nationality_and_unique_national_id_to_lab_patients:
nationality ENUM('saudi','non_saudi') NULL AFTER national_id_typeSELECT 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 manuallyUNIQUE INDEX lab_patients_company_national_id_unique (company_id, national_id)down() drops bothModules\LIS\Enums\Nationality with cases Saudi = 'saudi', NonSaudi = 'non_saudi' + a label() method for ar/en translationsLabPatient model: add 'nationality' to $fillable + cast to the EnumStoreLabPatientRequest validation changes:
name_ar: 'nullable' โ 'required'name_en: already 'required' from LIS-131 โ no changenational_id: 'nullable' โ 'required' + add Rule::unique('lab_patients', 'national_id')->where('company_id', auth()->user()->company_id)nationality: ['required', Rule::enum(Nationality::class)]UpdateLabPatientRequest: mirror but with ->ignore($this->route('patient')) on the unique rule so editing a patient doesn't conflict with itselfLabPatientResource::toArray(): expose nationality (with value/label object pattern matching other enum fields like gender)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"8 changes across 2 components (patient list page + request wizard inline-add). One PR on your repo, merged after BE PR lands.
nationality?: 'saudi' | 'non_saudi' to LisPatient interfaceCreateLisPatient as required| Arabic input | โ Correct (transliteration) | โ Wrong (translation) |
|---|---|---|
| ู ุญู ุฏ | Mohammed | Praised |
| ุนุจุฏุงููู | Abdullah | Servant of God |
| ูุงุทู ุฉ | Fatima | One who weans |
| ููุฑ | Nour | Light |
| ุฃู ู | Amal | Hope |
src/app/core/services/arabic-name-transliterator.service.ts (note: "name-transliterator" โ explicit about scope){ 'ู
ุญู
ุฏ': '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.ุฃโ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.ู
ุญู
ุฏ ุจู ุนุจุฏุงููู ุงูุดู
ุฑู:
Mohammed bin Abdullah Al-Shamri (joined with single spaces)transliterateName(arabic: string): string // 'ู ุญู ุฏ ุนูู' โ 'Mohammed Ali' // 'ูุงุทู ุฉ ุงูุดู ุฑู' โ 'Fatima Al-Shamri' // 'ุณู ูุฑ' โ 'Samir' (dictionary hit) // 'ููุงููุด' โ 'Klakwsh' (letter-by-letter โ name not common)
name_ar control's valueChanges (debounced 300ms)name_en is empty OR was last set by the transliterator (track via a private flag), call transliterateName(arabic) and patch name_enname_en, set userEditedEn = true and stop auto-syncing โ they own that field from then onname_en resets userEditedEn = false and re-runs the transliteratorRecord<string, string> โ easy to expand by hand later.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.
quickAddVisible, quickAddForm, openQuickAdd(), saveQuickAdd(), the entire Quick Add dialog block in the template, "QUICK_ADD" translation keys (ar + en)name_ar: add Validators.requirednational_id: add Validators.requirednationality: new control ['', Validators.required]blood_group: remove control + UI block (delete the <p-select> for it)computeAge(dob: Date): {y,m,d} + computeDobFromAge({y,m,d}): DatelastEditedField: 'dob' | 'age' to prevent the dobโage sync from looping~ badge next to the DOB to flag it as "approximate"date_of_birth โ age is never sent to the BEname_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 editsnational_id_type default (NATIONAL_ID vs IQAMA) but don't lock itsrc/app/features/lis/request-wizard-v2/request-wizard-v2.component.ts (newPatientForm block, currently line 549+)name_ar required, national_id required, nationality required, remove blood_group, age display, transliterator hook, 422 handlingsrc/app/core/services/lis-barcode-label.service.tsBarcodeLabel interface: rename dob โ age (or add age and deprecate dob)age from DOB and pass it in42Y 3M instead of 1983-04-15PATIENTS.NATIONALITY, PATIENTS.SAUDI, PATIENTS.NON_SAUDI, PATIENTS.AGE_FORMAT ({{y}}Y ยท {{m}}M ยท {{d}}D), PATIENTS.DUPLICATE_NATIONAL_IDPATIENTS.QUICK_ADD, PATIENTS.BLOOD_GROUP (if unused after this change)git pull + artisan migrate + artisan optimize + composer dump-autoload --classmap-authoritative on dev. Then merge FE branch to main and rebuild Angular bundle.| Question | Default (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 |