# Admin · Catalogue — Business Rules

Rules the demo encodes (`portals/admin/Catalogue.tsx`, `store/store.ts:1789-1808`,
`types.ts:161-165`, `store/catalogue.test.ts`). Each is numbered and implementable.
"Ours" = `ShopCategoryController.php` / `ShopCategory` / `backend/views/shop-category/*`.

## Taxonomy structure
1. A category is `{ id, name, parentId? }`. **Exactly one level of nesting** is
   permitted: a *group* (no `parentId`) may contain *child* categories; a child may
   NOT itself be a parent. (`types.ts:161-165`; store comment `store.ts:1788`).
   — Ours: **VIOLATED / N-A** — `shop_category` is flat, no `parent_id`.
2. The category taxonomy is **global**, shared across all shops (subtitle copy
   `Catalogue.tsx:105`; the store holds a single `categories[]`).
   — Ours: matches — `shop_category` is global (admin-owned).

## Validation
3. Category **name is required**: the save button is disabled while `name.trim()` is
   empty (`Catalogue.tsx:148`); the saved name is trimmed (`:79`, `:82`).
   — Ours: matches — `[['name'],'required']` (`base/ShopCategory.php` rules).
4. On edit, a group **cannot be selected as its own parent** — the parent `<Select>`
   excludes `editing.id` (`Catalogue.tsx:162`).
   — Ours: N-A (no parent concept).
5. Only **groups** (categories without a `parentId`) are offered as parents — you can
   never nest under a child, enforcing the single-level rule (`:161`, parent list is
   `groups`, where `groups = categories.filter(c => !c.parentId)`, `:63`).
   — Ours: N-A.

## Delete semantics
6. Deleting a **group cascades** to all its children (both group and children rows are
   removed) (`store.ts:1797-1806`; `catalogue.test.ts:60-67`).
   — Ours: N-A (flat); `deleteWithRelated()` removes the row + assignment rows but
   there are no children to cascade.
7. On delete, **services keep existing but become uncategorised**: any service whose
   `categoryId` is in the removed set has `categoryId` cleared, while its legacy
   `category` string is preserved as a display fallback
   (`store.ts:1801-1804`; `catalogue.test.ts:68-71`; UI copy `Catalogue.tsx:195`).
   — Ours: **PARTIAL/UNVERIFIED** — `deleteWithRelated()` likely removes the
   `service_category_assignment` join rows; there is no documented "keep service,
   clear link, retain legacy label" semantic, and no legacy string column.

## Counts (presentation rule)
8. Per category, the list shows **service count** and **distinct shop count**. A
   service is counted toward a category by `categoryId`, falling back to matching the
   legacy `category` string to a category `name` (`Catalogue.tsx:34-46`). Shop count is
   the number of distinct `shopId`s among those services.
   — Ours: **MISSING in list** — relations exist (services/shops) and render on the
   detail page (`view.php:88`,`:122`), but no inline count and no legacy-name fallback.

## Category↔service linkage source-of-truth
9. `categoryId` is the **source of truth**; the legacy `category` string is only a
   fallback for un-migrated services (`types.ts:224-225`;
   `selectors.categoryNameFor` prefers id then string, tested
   `catalogue.test.ts:24-31`).
   — Ours: linkage is via the `service_category_assignment` / `shop_category`
   relations; no legacy-string fallback model.

## Permissions / scoping
10. Catalogue is an **admin-portal** screen (lives under `portals/admin`). Implicitly
    admin-only; the demo has no per-row shop scoping (it is global data).
    — Ours: matches intent — `ShopCategoryController::beforeAction` gates managers via
    `checkPermissions("$controller_$action")`, full admins pass through
    (`ShopCategoryController.php:21-36`). Backend is admin-scoped.

## Bilingual (project rule, beyond the demo)
11. Per `CLAUDE.md`, every UI string must exist in `ar` and `en`. Demo strings are
    English-only literals (no i18n layer in the mockup).
    — Ours: matches — list/form strings are `Yii::t(...)` and present in
    `common/messages/{ar,en}/backend.php` (verified e.g. "Shops Categories",
    "Drag rows by the handle…"). The category **name itself** is bilingual via
    `MultiLanguageBehavior` — an ours-only capability the demo lacks (demo stores a
    single `name`; service/freebie types carry `nameAr`, but `ServiceCategory` does
    NOT — `types.ts:161-165`).

## Rules ours adds that the demo does not encode
- A1. Categories carry an explicit **display order** (`sort_order`), editable by drag
  or arrow buttons, and `ShopCategory::ordered()` is the canonical sort for admin,
  customer dropdowns, and API (`ShopCategory.php:18-26`).
- A2. A category may carry a **cover image** (`image_path`/`image_base_url`,
  filekit upload, fit to 215×215 `ShopCategoryController.php:55-65`).
- A3. Categories can be directly **assigned to specific shops** (shop↔category
  many-to-many) — the demo derives shop association indirectly from services.
