---
title: LIS First-Run Setup Screen (Lab Setup wizard)
slug: lis-setup-screen
status: shipped
owner: hazem
updated: 2026-06-19
related:
  - production
plans:
  - https://moonui.elbaset.com/knowledge-base/plans/lis-setup-screen-plan.html
---

# LIS First-Run Setup Screen — task record

**Goal:** a guided **Lab Setup wizard** at `/lab-setup` that appears on first run (+ a permanent nav link), configuring everything a lab needs to operate, including an **optional import of the current curated investigations/panels/prices** ("house catalog") or the NAFIS 1,666 catalog or a CSV. **Accounting settings are included** in the same screen (owner requirement). Built on **moonui / `hazemdev`**, code-reviewed per task.

## Approved decisions (owner, 2026-06-19)
1. **Import source = the CURRENT curated data** — the real investigations + panels + prices already in the live DB ("house catalog"), PLUS NAFIS (1,666) and CSV as alternatives.
2. **First run = optional** (dismissible banner + permanent link), not forced.
3. **Pricing** = pull from the house catalog by default + allow bulk edit (flat/percent).
4. **Scope = MVP first** (Identity, Reference Data, Investigations import, Pricing, Accounting+Lab accounts, Finish); People/Insurance/Analyzers = phase 2.
5. **Where = moonui / `hazemdev`** (not the parallel moonui2).
6. **Accounting settings included** in this screen (default-accounts Accounting section + lab accounts).

## Key data insight (moonui_dev_be, read-only)
1,819 investigations (20 real panels, 138 panel members, 1,244 normal ranges), but **prices are in `lab_price_list_items` (6,469 items), NOT `lab_investigations.price`** (only 20 rows have price>0). → the **house-catalog export must read prices from the default price list**; the import must recreate a price list. House catalog = the lab's curated tests (e.g. `sbscs_code IS NULL` / priced), excluding the bulk hidden NAFIS rows.

## Installer seeder gap (must fix)
Only `LabSettingDefinitionSeeder` + `LabDeviceModelSeeder` run in `config/moonstack.php → installer.seeders`. The catalog seeders (`LabSectionSeeder`, `LabSpecimenTypeSeeder`, `LabUnitSeeder`, `LabDoctorSpecialtySeeder`, `LabDeferralReasonSeeder`, `LabHistopathTemplateSeeder`) are NOT in install/update → fresh installs can have empty catalogs. **Fix:** add them (idempotent) to installer + updater seeders; the setup screen can also seed/verify on demand.

## API contract (BE + FE built to this in parallel)
- `GET /lis/setup/status` → `{lab_name_set, sections_count, specimen_types_count, units_count, investigations_count, real_investigations_count, has_default_price_list, accounts_wired:{receivable,revenue,tax}, setup_completed}`
- `POST /lis/setup/seed-catalogs` → run catalog seeders idempotently → counts
- `POST /lis/setup/complete` → set `lis.setup_completed=1`
- `GET /lis/investigations/export` → JSON snapshot (investigations + ranges + panels + default-price-list prices) of the curated set
- `POST /lis/investigations/import-standard {source:"house"|"nafis", skip_existing, with_prices}` → recreate (idempotent) → counts. `house` reads a bundled snapshot generated from current data; `nafis` reuses `NafisCatalogSeeder::seedForCompany`
- `POST /lis/investigations/import` (CSV) — existing, reuse
- `POST /lis/price-lists/bulk-setup {name, set_default, pricing_mode:"from_house"|"flat"|"percent"|"keep", flat_price?, percent?, only_active}` → default price list + items
- `lis.setup_completed` setting definition (added to `LabSettingDefinitionSeeder`)

## Wizard steps (7, shipped) — route `/lab-setup` (+ `/lab/lab-setup`)
0 Identity (reuse `LisLabInfoComponent`) · 1 **Locale & Main Branch** · 2 Reference Data (seed-catalogs) · 3 Investigations Import (House/NAFIS/CSV/skip) · 4 Pricing (bulk-setup) · 5 **Accounting + Lab accounts** (reuse `default-accounts.config.ts` — Accounting **and** Lab sections; detail-leaf guard, avoids AR-header trap) · 6 Finish → `lis.setup_completed`. + dismissible first-run banner on the lab dashboard via `/lis/setup/status` + permanent "Lab Setup" nav link.

### Locale & Main Branch step — SINGLE SOURCE OF TRUTH (owner requirement)
timezone, base currency (+ symbol/code), and main branch must NOT be a lab-specific copy — they write the **company's own source**, exactly what the global company setup uses:
- **Timezone** → `Company.timezone` via `PUT /core/company` (raw HttpClient, like global setup).
- **Currency** → the base `Currency` record (`is_base`, via `CurrencyService.create/update`) + `Company.currency` code via `PUT /core/company`. Symbol/code read from the `Currency` model. **Never writes the `lis.currency` setting** (that key is only a display-override fallback to `Company.currency`).
- **Main branch** → `Branch.is_main` via `BranchService.setMain(id)` (auto-demotes others); creates a branch if none exist.
All BE endpoints already existed — this step is FE-only.

## Reuse map
`LisLabInfoComponent` / `LisLabInfoService` (identity), `default-accounts.config.ts` + Default-Accounts logic (accounts, incl. Accounting), `LisPaymentMethodsComponent` (later), existing `POST /lis/investigations/import` (CSV), `NafisCatalogSeeder`, `setup-wizard.component.ts` (the pattern to mirror).

## 🗺️ Architecture map — every file + its role (read this before changing the wizard)
**Backend** (`/home/moonui/moon-erp-be/Modules/LIS`, + `config/moonstack.php`):
| File | Role |
|---|---|
| `app/Http/Controllers/LisSetupController.php` | `GET setup/status`, `POST setup/seed-catalogs` (**company-scoped** — calls each catalog seeder's `seedForCompany($companyId)`, NOT `db:seed`), `POST setup/complete` (sets `lis.setup_completed`). |
| `app/Http/Controllers/LisCatalogController.php` | `GET investigations/export`, `POST investigations/import-standard`. |
| `app/Services/LisCatalogService.php` | Export (curated/all/priced/**house** snapshot) + import-standard logic: **house** = load `house_catalog.json` → create investigations (idempotent by `code`) + ranges + panel-member links (2nd pass by code) + prices via `ensureDefaultPriceList`; **nafis** = `NafisCatalogSeeder::seedForCompany`. **`replace` mode** = `wipeCatalog($companyId)` (delete panel-member links + price-list items + ranges, then **forceDelete** investigations) FIRST, inside the same `DB::transaction` → atomic + FK-safe (rolls back if any test is still referenced by requests/results). All in `DB::transaction`. |
| `app/Http/Controllers/LabPriceListController.php` | `bulkSetup()` + `repriceItems()` — default price list + bulk pricing (`from_house`/`flat`/`percent`/`keep`); `percent` bounded `min:1,max:10000`. |
| `database/data/house_catalog.json` | Bundled self-contained snapshot — **150 tests + 20 panels (+119 member links) + 166 ranges + 150 prices** (2026-06-19). Regenerate via the **`house`** scope: `GET /lis/investigations/export?scope=house` (or `(new LisCatalogService)->exportCatalog($companyId,'house')`). **⚠️ The old `curated` scope (`sbscs_code IS NULL`) shipped only 1 of 20 panels** — real panels carry an SBS code so they (and their NAFIS-coded analyte members) were filtered out. The `house` scope = `curated ∪ all panels ∪ all panel members`, so panels import complete. |
| `database/seeders/NafisCatalogSeeder.php` | `seedForCompany()` **public** — NAFIS 1,666 (idempotent by `sbscs_code`). |
| `database/seeders/Lab{Section,SpecimenType,Unit,DoctorSpecialty,DeferralReason,HistopathTemplate}Seeder.php` | `seedForCompany()` **public** (idempotent `firstOrCreate` by company+code). |
| `database/seeders/LabSettingDefinitionSeeder.php` | `lis.setup_completed` definition. |
| `routes/api.php` | the 6 routes (setup/* + investigations/export,import-standard + price-lists/bulk-setup). |
| `config/moonstack.php` | the 6 catalog seeders added to `installer.seeders` **and** `updater.seeders`. |

**Frontend** (`/home/moonui/public_html/moon-erp/src/app`):
| File | Role |
|---|---|
| `features/lis/lab-setup/lab-setup-wizard.component.ts/.html/.scss` | the 7-step wizard (signals + p-stepper, mirrors `features/setup/setup-wizard.component.ts`). |
| `features/lis/lab-setup/lab-setup-accounts.config.ts` | accounts step config — reuses `DEFAULT_ACCOUNT_SECTIONS` narrowed to **`lis` + `accounting`** sections. |
| `core/services/lis-setup.service.ts` | `/lis/setup/*` (status/seed-catalogs/complete). |
| `core/services/lis-investigation.service.ts` | added `importStandard` / `importCsv` / `exportCatalog`. |
| `core/services/lis-price-list.service.ts` | added `bulkSetup`. |
| `app.routes.ts` + `features/lis/lis-standalone.routes.ts` | routes `/lab-setup` and `/lab/lab-setup`. |
| `core/config/nav-items.config.ts` + `features/lis/lis-layout/lis-layout.component.ts` | "Lab Setup" nav item (permission `lis.settings`). |
| `features/lis/dashboard/lis-dashboard.component.*` | dismissible first-run banner (uses `setup_completed`; localStorage dismissal). |
| `assets/i18n/en.json` + `ar.json` | `LAB_SETUP.*` (88 keys each) + `NAV.LAB_SETUP`. **English-first** (verified: all keys resolve in `en.json`, data dropdowns show `name_en` first). |
| **Reused** | `features/lis/lab-info/*` (identity step), `features/setup/setup-wizard.component.ts` + `default-accounts.config.ts` (pattern + accounts logic), `core/services/{currency,branch}.service.ts` + raw `PUT /core/company` (locale step). |

## 🔧 How to extend / maintain
- **Add a wizard step:** insert a `@if(currentStep()===N)` panel in the HTML, **renumber every downstream guard**, bump `onEnterStep`'s lazy-load index, extend the `steps[]` array + i18n. (See the locale-step insert at index 1 for the renumber pattern — easy to get off-by-one.)
- **Add a new GL account setting (STANDING RULE [[feedback_default_accounts_complete]]):** wire it in `seedDefaultAccountSettings` + `default-accounts.config.ts` (lis/accounting section) → it **auto-appears** in the wizard's accounting step. Always a postable detail leaf, never a header.
- **Locale single-source:** timezone/currency/main-branch write the **company** source (`PUT /core/company`, `CurrencyService`, `BranchService.setMain`). NEVER write `lis.currency` (display-fallback only). On reload, read currency from **`company.currency`** (not `is_base` — see bug below).
- **Import sources:** house (`house_catalog.json`, regenerate from the export endpoint), NAFIS (`NafisCatalogSeeder`), CSV (`ImportInvestigationsJob` — `POST /lis/investigations/import`).
- **First-run gate:** `lis.setup_completed` setting + `GET /lis/setup/status` (drives the banner + the wizard checklist).
- **Saves happen on the step's Next button** (each `save*()` → PUT/POST → `nextStep()`), matching the global setup wizard — there is no auto-save on field change.

## 🐞 Bugs found + fixed (post-ship, 2026-06-19)
- **Locale step "reverts" on reopen** (FE `0657947`): `loadLocale` chose the currency via `currencies.find(is_base)`, but the data has **3 currencies flagged `is_base=true`** (EG, EGP, SAR) so it returned the wrong one (EG) instead of the real `company.currency` (SAR) → looked like the selection didn't save. **The save was always correct** (verified live: `PUT /core/company {timezone,currency}` → 200, persists). Fix: read currency from **`company.currency`** (the app-wide source of truth), and **decouple** the timezone PUT from the currency PUT so a currency `exists:currencies,code` validation error can't roll back the timezone. **is_base cleanup — DONE (BE `fd682bae1`, 2026-06-20):** company 4 had **3** currencies with `is_base=true` (EG, EGP, SAR) because each setup "create base currency" added one without demoting the old, and `UpdateCurrencyRequest` didn't even include `is_base`. Fixed: (1) cleaned the data (only SAR = `company.currency` stays base — verified count=1); (2) **single-base invariant** in `CurrencyController` — `store`/`update` now demote every other `is_base` row for the company when one is set base, and `is_base` is allowed on update (verified live: set EUR base → SAR demoted → restore SAR → EUR demoted). So "the base currency" is now unambiguous everywhere, not just via the wizard's `company.currency` preference.
- **CSV import "doesn't work"** (BE `74375167d`, FE `bbda0d9`): two causes. (1) `LabInvestigationController::import` returned **HTTP 422 whenever `created==0`**, so an all-existing/all-skip file read as an error in the wizard. Fix: return **200** unless there's a genuine parse failure (errors present + nothing created). (2) The picker accepted **`.xlsx/.xls`** but the importer is `fgetcsv` (**CSV-only**) and **no Excel parser is installed** → any Excel file → 0 created → 422. Fix: restrict BE mimes + FE picker to **CSV** (honest — Excel never parsed). **Verified live:** new test → 200/created:1; re-upload → 200/skipped:1.
- **Logo upload "broken/rejected image"** (BE `74375167d`, FE `bbda0d9`): picker allowed any `image/*` but the BE accepted only `png,jpg,jpeg,svg` → a phone HEIC / webp / gif → **422**. Fix: added **`webp`** to the BE mimes + restricted the FE picker to `.png,.jpg,.jpeg,.svg,.webp`. (HEIC still unsupported — needs a server-side converter; the picker now blocks it + the BE returns a clear message.) **Verified live:** webp → 200 (backed up → restored the real logo).
- **PROCESS ([[feedback_verify_before_done]]):** both were found by the owner AFTER I claimed "done" → **always exercise the real upload path** (curl a real multipart file) before reporting done; use the **Codex agent** to double-check. **Codex review then caught 2 more** I'd missed: (a) the EXISTING `/lis/investigations` page picker still offered `.xlsx/.xls` after the shared BE mime was restricted to csv,txt → fixed to `.csv` (FE `c5d051f`); (b) **SVG logos were silently rejected** — Laravel's `image` rule excludes svg unless `image:allow_svg`, so `mimes:...,svg` alone never worked (pre-existing) → fixed with `image:allow_svg` (BE `cde6339dd`), **verified live (svg → 200)**. Lessons: when restricting a SHARED BE validation, grep ALL FE callers; and `image` + `mimes:svg` ≠ svg actually accepted.

- **House catalog was missing 19 of 20 panels** (BE `3e5821a56`, 2026-06-19): the bundled `house_catalog.json` was generated with `scope=curated` (`sbscs_code IS NULL`) → only **1 of 20 panels** shipped, because real panels carry an SBS code (and their analyte members are NAFIS-coded) so the filter dropped them. Fix: new **`house` scope** = `curated ∪ all panels ∪ all panel members` (self-contained); regenerated `house_catalog.json` → **20 panels / 150 tests / 119 member links / 166 ranges / 150 prices** (from company 4). Verified by Pest (`LisCatalogReplaceTest`: "house import seeds all 20 panels"). Owner-reported: "البنل مش بييجي".
- **NEW: password-gated "Replace existing tests" option** (BE `3e5821a56`, FE `ae69c5b`, 2026-06-19): owner wanted, from the setup screen, to **wipe the current catalog and load the house catalog in its place**. `import-standard` now takes `replace` + `password`; the controller verifies the current user's password (`Hash::check`), then `LisCatalogService::wipeCatalog` hard-deletes (panel links + price-list items + ranges + `forceDelete` investigations) **inside the import transaction** (atomic; forceDelete frees the global-unique `code` for re-insert; FK from existing requests/results → friendly 422 rollback). FE: a "Replace existing tests" checkbox + password field under the House option (English-first + ar). Verified: gate live (wrong/no password → 422, no wipe); Pest "replace wipes+reimports with identical counts". The `replace` path is the clean answer to the global-unique-code limitation below for the **single-company fresh-install** case.

## ⚠️ Known limitation — house import is fresh-install-targeted
The LIS `code` columns (`lab_investigations`, `lab_sections`, `lab_specimen_types`, `lab_investigation_categories`, `lab_price_lists`) have a **GLOBAL unique on `code`**, not a compound `(company_id, code)`. So `import-standard {house}` / `seed-catalogs` work on a **fresh install** (empty DB, one company) — the real deployment model — but importing the same catalog into a **second company on the SAME DB** collides (500, transaction rolls back cleanly). On moonui's multi-company dev DB, importing into company 4 safely skips (codes already exist). **Proper fix (separate follow-up, NOT done — risky on live tables):** a migration replacing the global uniques with compound `(company_id, code)`. `seedCatalogs` is now company-scoped so it never seeds other tenants.

## Build status — ✅ SHIPPED on `hazemdev` (2026-06-19)
Built + reviewed + fixed via ~12 parallel agents (3 build [BE×2, FE], 3 code-review, 2 fix, 1 locale-step build + 1 review + 1 fix, + a company-source mapping). Verified live: read endpoints (`status`, `export`) + write endpoints (`seed-catalogs` company-scoped, `import-standard {house}` idempotent skip) on company 4; FE AOT build clean + deployed to `moonui.elbaset.com/app`.
- **Commits:** BE `5cc432f48`, FE `30aac50`, FE locale-revert fix `0657947` (hazemdev). CHANGELOG `[Unreleased]` updated.
- Constraints honored: ran artisan as `moonui`; `bash local-deploy.sh` after BE edits; **never wiped the real DB** ([[feedback_never_test_on_real_db]]); English-first UI.
- Code-review caught + fixed: 🔴 `seedCatalogs` cross-tenant blast-radius (→ company-scoped); 5 FE↔BE contract mismatches (FE assumed `{data}` envelopes, BE returns flat); `bulkSetup` percent unbounded; redundant range-delete; nav permission `lis.settings.manage`→`lis.settings`; `with_prices` default→true; locale step's currency-dropdown blank + swallowed company-PUT failure + zero-branch guard + load race.
Full design: [plan](https://moonui.elbaset.com/knowledge-base/plans/lis-setup-screen-plan.html). NOT yet merged to `main`.

## NAFIS codes in the house catalog (2026-06-26, BE `574762a73`)
NAFIS = Saudi national lab coding. Stored on `lab_investigations`: `sbscs_code`, `nafis_name`, `nafis_block` (migration `2026_05_31_140000`; all fillable; FE edit/view in the investigation form). **Bug:** the house-catalog import (`LisCatalogService::importHouse`, fields driven by `CORE_FIELDS`) dropped the NAFIS fields → a lab seeded from the standard catalog started with NAFIS empty (**elmadina = 0/1833**, moonui co4 = 1703/1826). The `source=nafis` path (`NafisCatalogSeeder`, uses `nafis_links.csv` 231 + `nafis_catalog.csv` 1666) populates them, but house import didn't. **Fix (B):** added `sbscs_code,nafis_name,nafis_block` to `CORE_FIELDS` (so export+import carry them) + regenerated `Modules/LIS/database/data/house_catalog.json` enriched from moonui co4 by `code` (1825/1825 matched → 1703 nafis_name + 1757 sbscs baked in, format preserved). Fresh installs / re-seeds now get NAFIS automatically. **✅ Done (A) — elmadina backfilled (2026-06-26, owner-authorized live write):** one-time cross-DB `UPDATE elmadina_db.lab_investigations e JOIN moonui_dev_be...m ON m.company_id=4 AND m.code=e.code SET nafis_name/nafis_block/sbscs_code = COALESCE(e.*, m.*)` (fills NULLs only, 1:1 join). elmadina 0 → **1700 nafis_name + 1700 nafis_block + 1752 sbscs** (1752 rows; matches moonui co4). Join key everywhere = investigation `code` — reusable for any other client missing codes.
