# Admin · Catalogue — Parity Matrix

Demo (canonical): `/private/tmp/Navagoo_MI_dev/navagoo-app/src/portals/admin/Catalogue.tsx`
Ours: `backend/controllers/ShopCategoryController.php`, `common/models/ShopCategory.php`,
`backend/views/shop-category/*`.

Scope: the admin Catalogue manages only the **global service-category taxonomy**.
Services/bundles/packages are authored in the shop portal, not here.

| # | Demo behavior | Demo ref | Our ref | Status | Note |
|---|---|---|---|---|---|
| 1 | Single admin page listing all global categories | Catalogue.tsx:95-136 | views/shop-category/index.php; ShopCategoryController.php:76-87 | done | Page vs SPA card; both list global categories |
| 2 | One level of **nesting**: groups + child categories | types.ts:161-165; store.ts:1788 | — | missing | `shop_category` has no `parent_id`; flat only |
| 3 | Group/child visual hierarchy (indent, Group badge, Folder vs Tag icons) | Catalogue.tsx:109-119 | — | missing | Flat table rows |
| 4 | Inline per-row **service count** | Catalogue.tsx:34-46,122 | view.php:88 (detail only) | missing | No inline count in list; relation shown on detail page |
| 5 | Inline per-row distinct **shop count** | Catalogue.tsx:43,123 | view.php:122 (detail only) | missing | Same as #4 |
| 6 | Service→category resolve via `categoryId` w/ legacy-name fallback | Catalogue.tsx:38; types.ts:224-225 | — | missing | No legacy-string fallback model |
| 7 | Add category (name + optional parent) | Catalogue.tsx:65-86,139-171; store.ts:1791 | ShopCategoryController.php:117-145; _form.php | partial | Add works; **no parent field** |
| 8 | Edit category | Catalogue.tsx:71-86; store.ts:1793 | ShopCategoryController.php:153-183; _form.php | partial | Edit works; no parent field |
| 9 | Name **required**, trimmed; save disabled when empty | Catalogue.tsx:79,82,148 | base/ShopCategory.php rules `[['name'],'required']` | done | Server-side required validation |
| 10 | Parent select lists only groups, **excludes self** | Catalogue.tsx:161-162 | — | missing | No parent concept |
| 11 | Delete category | Catalogue.tsx:87-91; store.ts:1795 | ShopCategoryController.php:191-196 (deleteWithRelated) | partial | Deletes row; semantics differ (see #12,#13) |
| 12 | Delete **cascades to children** | store.ts:1797-1806; catalogue.test.ts:60-67 | — | missing | No children to cascade |
| 13 | On delete, services become **uncategorised** (clear categoryId, keep legacy string) | store.ts:1801-1804; catalogue.test.ts:68-71; Catalogue.tsx:195 | ShopCategoryController.php:193 | missing | `deleteWithRelated` drops join rows; no keep-service-uncategorised semantic |
| 14 | Delete-confirm modal with cascade/uncategorise warning | Catalogue.tsx:174-198 | index.php delete via data-method=post | partial | Browser confirm only; no warning copy |
| 15 | Toasts on add/update/delete | Catalogue.tsx:80,83,89 | session flash (controller setFlash) | done | Flash messages instead of toasts |
| 16 | Empty list (renders nothing) | Catalogue.tsx:107 | index.php:160-161 ("No results found.") | done | Ours has explicit empty copy (better) |
| 17 | RTL-safe layout | Catalogue.tsx logical props | index.php logical props + Arabic i18n | done | Both RTL-aware |
| 18 | Bilingual UI strings (project rule) | — (demo EN literals) | Yii::t + ar/en backend.php | done | Verified keys present |
| A1 | Display order + drag/arrow reorder | — | ShopCategoryController.php:206-327; index.php:47-91 | ours-only | Not in demo |
| A2 | Category cover image upload | — | ShopCategoryController.php:55-65; _form.php:92-100 | ours-only | Not in demo |
| A3 | Bilingual category **name** | — | MultiLanguageBehavior; index.php:165 | ours-only | Demo `ServiceCategory` has no `nameAr` |
| A4 | Direct shop↔category assignment | — | ShopCategoryController.php:373-383; shop_category_assignment | ours-only | Demo derives shops from services |
| A5 | Pagination + name search filter | — | index.php:124-143,219-231 | ours-only | Demo is unfiltered/unpaged |

## Summary
The two surfaces overlap on "manage a global service-category list", and ours is
richer on ordering, imagery, bilingual names and shop assignment. But the demo's
**defining feature — a one-level group/child hierarchy with cascade-delete and
service-uncategorise semantics, plus inline svc/shop counts — is entirely absent**
from ours. Those are the canonical target behaviours and they are missing.

Done: 7 · Partial: 4 · Missing: 7 (of 18 demo rows; A* are ours-only extras).
Weighting partial as 0.5: (7 + 4×0.5) / 18 = 9/18.

**Area score: 50%**

---

## Verified verdict (adversarial)

Re-audited each `done` / `ours-only` claim against actual code. Demo:
`portals/admin/Catalogue.tsx`; store delete cascade `store/store.ts:1795-1807`.

### Overclaims downgraded

| # | Feature | Analyst | Verified | Evidence |
|---|---|---|---|---|
| 9 | Name required **+ trimmed** | done | **partial** | `common/models/base/ShopCategory.php:62` has `[['name'],'required']` only — **no `trim` filter**. Demo trims (`Catalogue.tsx:79,82`) AND disables save on whitespace-only (`:148`). Ours accepts `"   "` (Yii `required` passes non-empty string) and never trims. Required ✓, trimmed ✗. |
| 15 | Toasts on add/update/delete | done | **partial** | Controller sets `setFlash('alert'/'success', …)` (`:127,164,168`) **but the Tailwind admin layout renders no flashes at all** — `backend/views/layouts/tailwind.php:96-104` outputs only `$content`; no `getAllFlashes`/growl in layout or `_tw_admin_navbar`/`_tw_admin_sidebar`. So create/update flashes are set-but-invisible, and **`actionDelete` (`:191-196`) sets no flash whatsoever**. Net user-visible toast feedback ≈ none on these pages. |
| A4 | Direct shop↔category assignment (ours-only) | done | **missing (on this surface)** | `ShopCategory::getShops()` = `hasMany(Shop,['category_id'=>'id'])` (`base/ShopCategory.php:117-120`) — a **read-only derived** list off `shop.category_id`, shown only on `view.php:121-`. The catalogue `_form.php` has **no shop field** (image + name only). `actionAddShop` (`:373`) renders `_formShop`, but **no catalogue view/form references it** (`grep` clean). The `shop_category_assignment` table exists in the schema but is **not wired into this page**. Claim of "direct assignment here" is unsupported. |

### Confirmed as claimed

| # | Feature | Status | Note |
|---|---|---|---|
| 1 | Single admin page listing all global categories | done | `actionIndex` + `index.php` table; list works via `ShopCategoryQuery::orderedBySequence()` (`ShopCategoryQuery.php:27`). |
| 16 | Empty-list state | done | `index.php:160-161` explicit "No results found." (key present `en:498`/`ar:357`). Better than demo's render-nothing. |
| 17 | RTL-safe layout | done | `index.php` uses logical props (`ps-`/`pe-`/`text-start`/`ms-auto`); Arabic i18n present. |
| 18 | Bilingual UI strings | done | All catalogue keys present in **both** `en/backend.php` & `ar/backend.php` (verified: Shops Categories, No results found., reorder labels, Move up/down, Drag to reorder, Search by name…, Invalid order payload, Could not reorder/save order). |
| A1 | Display order + drag/arrow reorder | done (ours-only) | `actionMoveUp/Down/SaveOrder` (`:206-266`), transactional swap (`:275-327`), jQuery-UI sortable (`index.php:47-91`). Genuine extra. |
| A2 | Category cover image upload | done (ours-only) | `actions() image-upload` + Intervention fit 215×215 (`:55-65`); filekit `Upload` in `_form.php:96-111`; `image` `file` rule (`base:65-77`). Genuine extra. |
| A3 | Bilingual category **name** | done (ours-only) | `MultiLanguageBehavior` on `name` (`base/ShopCategory.php:150-160`); `name (en)` column (`index.php:155,202`). Demo `ServiceCategory` has no `nameAr`. Genuine extra. |
| A5 | Pagination + name search filter | done (ours-only) | `ShopCategorySearch` `like name` (`search/ShopCategorySearch.php:71`); `index.php` GET filter (`:124-143`) + `LinkPager` (`:222-229`). Genuine extra. |

### Missing rows — confirmed (no change)
2,3,4,5,6,10,12 confirmed **missing** (no `parent_id`, no hierarchy/icons, no inline svc/shop counts in list, no legacy-name fallback, no parent select, no child cascade). #13 confirmed **missing & worse**: demo uncategorises services keeping legacy string (`store.ts:1801-1804`); ours `deleteWithRelated()` (`:193`) has no uncategorise path, and `Service.category_id` is **`required`** (`base/Service.php:60`) so a nulled/orphaned service would be invalid — data-integrity divergence. #7,#8,#11,#14 remain **partial** (CRUD works, no parent field; delete is a browser `confirm` with no cascade/uncategorise warning copy).

### Adjusted score

Demo rows (1–18), partial = 0.5:
- done: #1, #16, #17, #18 = **4**
- partial: #7, #8, #9 (was done), #11, #14, #15 (was done) = **6 × 0.5 = 3**
- missing: #2,#3,#4,#5,#6,#10,#12,#13 = **8 → 0**

(4 + 3) / 18 = 7 / 18 = **38.9%**.

A4 reclassified from a credited "ours-only" extra to **not present on this surface** (it was not in the 18-row demo denominator, so it doesn't change the percentage, but the analyst's qualitative "richer on shop assignment" claim is withdrawn).

**Adversarial area score: 39%** (down from analyst's 50%). Two `done` rows (#9 trimmed, #15 toasts) were overclaims, and the headline "direct shop assignment" extra (A4) does not exist on the catalogue page.
