# Dinex — Frontend Changes · Sprint 3

> Backend `dev` `dbbe746`..`b71c156` (2026-10-17 → 2026-10-26) · not on `main` yet.
> Every change below is **OLD payload → NEW payload**. `// NEW` marks an added field.

| # | Change | Who |
|---|---|---|
| 1 | Order line takes `ingredients[]` + `addons[]` · new `total_price` formula | POS |
| 2 | Order line response: `type`, `parent_order_item_id`, `modifications[]` | POS · Kitchen |
| 3 | `GET /pos/items/view/{id}` — customise screen | POS |
| 4 | Menu items carry `has_replacement` / `has_addon` | POS · Dashboard |
| 5 | Item form: replacements per ingredient, add-ons by item | Dashboard |
| 6 | Ingredient categories CRUD + `sku` / category on ingredients | Dashboard |
| 7 | Mobile order line takes `addons[]` | Mobile |
| 8 | `rejected` callback carries `is_delivery_rejected` | Mobile |
| 9 | Item push: CostControl route + dry run | CostControl |

---

## 1 · Order create / update

`POST /pos/orders/create` · `POST /pos/orders/update`

**OLD**
```json
{
  "items": [
    { "id": 42, "qty": 2, "groupId": null }
  ],
  "total_price": 76.00
}
```

**NEW**
```jsonc
{
  "items": [
    {
      "id": 42,
      "qty": 2,
      "groupId": null,

      "ingredients": [                                                  // NEW, optional
        { "ingredient_id": 7, "status": "replaced", "replacement_ingredient_id": 9 },
        { "ingredient_id": 8, "status": "removed" },
        { "ingredient_id": 5, "status": "default" }
      ],

      "addons": [                                                       // NEW, optional
        { "id": 88, "qty": 1 }
      ]
    }
  ],
  "total_price": 91.00
}
```

- `status` = `default` | `replaced` | `removed`. `replacement_ingredient_id` required only when `replaced`.
- `default` means unchanged — sending it or omitting the line is the same thing.
- `addons[].qty` is the add-on's own quantity, not a per-meal multiplier.
- **`total_price` formula changed** (wrong value = 422):
  `Σ (item.price + Σ extra_charge) × qty` `+` `Σ addon.price × addon.qty`
  Swap `price` → `delivery_app_price` on every line, add-ons included, when the payment method has `enable_delivery_app_price` — and that method must then be the order's only payment.

**New 422s**

| Field | Cause |
|---|---|
| `items.0.ingredients.0.ingredient_id` | not in the recipe, or sent twice |
| `items.0.ingredients.0.status` | `removed` on a non-removable ingredient |
| `items.0.ingredients.0.replacement_ingredient_id` | not offered for that ingredient |
| `items.0.addons.0.id` | not offered by the meal, or sent twice |

---

## 2 · Order line response

Order view · kitchen list · reprint (`OrderItemResource`)

**OLD**
```json
{
  "id": 42,
  "name": "Chicken Caesar",
  "qty": 2,
  "price": "38.00",
  "total_price": 76.00,
  "used_delivery_app_price": false,
  "ingredients": [
    { "id": 7, "name_en": "Romaine", "name_ar": "خس روماني", "size": "120.000", "unit": "gram" }
  ]
}
```

**NEW** — 4 fields added, none removed
```jsonc
{
  "id": 42,
  "name": "Chicken Caesar",
  "qty": 2,
  "price": "41.00",
  "total_price": 82.00,
  "used_delivery_app_price": false,

  "order_item_id": 9001,                 // NEW · this line's own id
  "type": "item",                        // NEW · "item" | "addon"
  "parent_order_item_id": null,          // NEW · meal line an add-on belongs to

  "ingredients": [ ],                    // unchanged

  "modifications": [                     // NEW · only replaced + removed
    {
      "status": "replaced",
      "ingredient_id": 7,                // what the recipe had
      "original_name": "Romaine",
      "replaced_ingredient_id": 9,       // what was served
      "ingredient_name": "Iceberg",
      "size": "120.000",
      "extra_charge": "3.00"
    },
    {
      "status": "removed",
      "ingredient_id": 8,
      "original_name": "Croutons",
      "replaced_ingredient_id": null,    // null = removed
      "ingredient_name": null,
      "size": "0.000",
      "extra_charge": "0.00"
    }
  ]
}
```

- Empty `modifications` = cooked exactly per recipe.
- Lines come back ordered by id, so an add-on always follows its meal → indent it under `parent_order_item_id`. `type: "addon"` with `parent_order_item_id: null` is a standalone dish.
- `price` already contains the extra charges and the payment surcharge — never add `extra_charge` again on a ticket.
- ⚠️ Renamed mid-sprint: `original_ingredient_id` → `ingredient_id`, old `ingredient_id` → `replaced_ingredient_id`.

---

## 3 · Customise screen

`GET /pos/items/view/{id}` — **new endpoint**, everything the screen needs in one call.

```jsonc
{
  "success": true,
  "item": {
    "id": 42,
    "name": "Chicken Caesar",
    "price": "38.00",
    "has_replacement": true,                 // open the swap UI
    "has_addon": true,                       // open the add-on picker

    "ingredients": [
      {
        "id": 7,                             // → send as ingredients[].ingredient_id
        "item_ingredient_id": 301,            // recipe row id, display only
        "sku": "ING-007",
        "name_en": "Romaine",
        "name_ar": "خس روماني",
        "unit": "gram",
        "size": "120.000",
        "is_removable": true,                 // false → hide the remove control
        "sort_order": 1,
        "ingredient_category": { "id": 12, "name_en": "Leaves", "name_ar": "أوراق" },

        "replacements": [
          {
            "id": 9,                          // → send as replacement_ingredient_id
            "replacement_id": 55,             // config row id, display only
            "sku": "ING-009",
            "name_en": "Iceberg",
            "name_ar": "خس أيسبرغ",
            "unit": "gram",
            "size": "120.000",
            "extra_charge": "3.00",           // added to the MEAL's unit price
            "sort_order": 1
          }
        ]
      }
    ],

    "addons": [
      {
        "id": 88,                             // → send as addons[].id
        "sku": "ITM-088",
        "name": "Extra Chicken",
        "price": "12.00",
        "delivery_app_price": null,
        "image": { },
        "sort_order": 1
      }
    ]
  }
}
```

- A swap is valid only with an id from **that ingredient's own** `replacements[]`.
- `extra_charge` applies once per meal, whatever the qty. Removing an ingredient does **not** reduce the price.

---

## 4 · Menu / item responses

**OLD** — every item endpoint
```json
{
  "id": 42,
  "name": "Chicken Caesar",
  "price": "38.00",
  "ingredients": [
    { "id": 7, "name_en": "Romaine", "name_ar": "خس روماني", "size": "120.000", "unit": "gram" }
  ]
}
```

**NEW** — two flags everywhere; the full recipe shape only where the recipe is loaded
```jsonc
{
  "id": 42,
  "name": "Chicken Caesar",
  "price": "38.00",
  "has_replacement": true,     // NEW · server-computed, never send it
  "has_addon": true,           // NEW · server-computed, never send it
  "ingredients": [ ],          // see table below
  "addons": [ ]                // only on the endpoints marked "full" below
}
```

| Endpoint | `ingredients[]` shape |
|---|---|
| `GET /pos/items/view/{id}` | **full** — recipe lines with `replacements[]`, plus `addons[]` |
| `GET /dashboard/items/view/{id}` · `/list` | **full** — same as above |
| `GET /pos/categories/get-items/{id}` · `GET /pos/items/search` | **old flat shape** — no `replacements`, no usable `addons` |

- Use `has_replacement` / `has_addon` to badge an item in a list, then call `/pos/items/view/{id}` for the detail.
- Detect the shape by the presence of `replacements` inside an ingredient.

---

## 5 · Dashboard item form

`POST /dashboard/items/create` · `/update` — form-data

**OLD** (first design — **deleted**, drop it if you built it)
```
is_addon=1
has_replacements=1
replacements[default_item_id]=50
replacements[items][0][id]=9
replacements[items][0][extra_charge]=3
replacements[items][0][delivery_app_adjustment]=0
addons[0][id]=88
addons[0][max_per_meal]=2
```

**NEW** — replacements nest inside each ingredient row; add-ons are plain items
```
ingredients[0][id]=7
ingredients[0][size]=120
ingredients[0][is_removable]=1
ingredients[0][sort_order]=1
ingredients[0][replacements][0][id]=9              # ingredient id
ingredients[0][replacements][0][size]=120          # optional → original's size
ingredients[0][replacements][0][extra_charge]=3    # optional → 0, must be >= 0
ingredients[0][replacements][0][sort_order]=1

has_addons=1
addons[0][id]=88                                   # item id
addons[0][sort_order]=1
```

- **Gone:** `is_addon`, `has_replacements`, `replacements{default_item_id,…}`, `max_per_meal`, `delivery_app_adjustment`, and the endpoint `GET /dashboard/items/default-components`.
- `size` required and > 0 · ids unique · a replacement must share the original's **leaf ingredient category** · an add-on must be an item in `ASP_CAT_1000` or below.
- **Update:** no `ingredients` → recipe **cleared** · no `has_addons` → add-ons untouched · `has_addons=0` → cleared · `has_addons=1` → replaced (`addons` then required, min 1).
- Item delete → 422 while it is an add-on of any meal.

**Dropdown lookups** (max 50 rows, no paging — keep `search` wired)

| Endpoint | Returns |
|---|---|
| `GET /dashboard/items/replacement-candidates?ingredient_id=&search=` | `ingredients` of the same leaf category |
| `GET /dashboard/items/add-ons?category_id=&exclude_item_id=&search=` | add-on `items`; `category_id` narrows to that subtree (422 if outside it) |

---

## 6 · Ingredient categories (new screen)

A **second tree, separate from the menu categories**: type (0) → category (1) → sub category (2). Only `is_leaf` categories can hold ingredients.

`GET /dashboard/ingredient-categories/list?leaf_only=1&parent_id=`
```jsonc
{
  "id": 12,
  "cc_code": "CC-12",              // CostControl key
  "parent_id": 4,
  "level": 2,                      // 0 type · 1 category · 2 sub category
  "is_leaf": true,
  "name_en": "Leaves",
  "name_ar": "أوراق",
  "children_count": 0,             // use these two to enable/disable delete
  "ingredients_count": 17,
  "parent": { },
  "children": [ ]
}
```

`POST /dashboard/ingredient-categories/create`
```jsonc
{
  "name_en": "Leaves",
  "name_ar": "أوراق",
  "parent_id": 4,                  // optional · must not be a leaf, level < 2
  "is_leaf": true,                 // optional
  "cc_code": "CC-12"               // optional
  // level is DERIVED from the parent — do not send it
}
```

| Endpoint | Note |
|---|---|
| `GET .../list` | `leaf_only=1` = assignable only · `parent_id` = one node's children |
| `GET .../view/{id}` | with `parent` + `children` · 404 if missing |
| `PUT .../update` | names / `cc_code` / `is_leaf` only — **`parent_id` cannot change**, so no "move" action |
| `POST .../delete` | 422 while it has children or ingredients |

**Access:** `admin`/`superadmin` **and all four** `ingredients.*` permissions, reading included. Reported to the PO as probably unintended — don't design around it.

### Ingredient payload

**OLD**
```json
{ "id": 7, "name_en": "Romaine", "name_ar": "خس روماني", "unit": "gram" }
```

**NEW**
```jsonc
{
  "id": 7,
  "sku": "ING-007",                // NEW · now UNIQUE across ingredients
  "ingredient_category_id": 12,    // NEW · must be a leaf category
  "name_en": "Romaine",
  "name_ar": "خس روماني",
  "unit": "gram"
}
```

- The category field is **locked** while the ingredient is used in a replacement.
- Ingredient delete → 422 while it is used in a recipe or as a replacement.

---

## 7 · Mobile order line

`POST /pos/orders/mobile/create`

**OLD**
```json
{ "items": [ { "sku": "ITM-042", "qty": 2 } ] }
```

**NEW**
```jsonc
{
  "items": [
    {
      "sku": "ITM-042",
      "qty": 2,
      "addons": [                       // NEW, optional · by SKU
        { "sku": "ITM-088", "qty": 1 }
      ]
    }
  ]
}
```

- `qty` ≥ 1, absolute (not per meal). Must be an add-on the meal offers, otherwise the **whole order** fails with `items.0.addons.0.id`.
- A line without `addons` totals exactly as before — safe to ignore for now.
- Replacements / removals are **not** accepted from the platform.

---

## 8 · Mobile status callback

**OLD**
```json
{ "status": "rejected" }
```

**NEW** — one field, only on `rejected`
```jsonc
{
  "status": "rejected",
  "is_delivery_rejected": true    // NEW · true = delivery provider, false = branch cashier
}
```

- Absent on every other event (`approved`, `completed`, …) → treat absent as "not applicable", not `false`.
- The reason is still not sent.
- **An approved delivery order can turn `rejected` later**, on its own, with no cashier action. Keep listening until it is delivered.

---

## 9 · Item push (CostControl)

**OLD** — one route: `POST /pos/items/mobile/items-creation`
**NEW** — same handler also at `POST /pos/items/cc/item-creation`, plus `POST /pos/items/cc/item-creation-simulate` (dry run). Platform token only.

**Payload** — keyed on `sku`, update-or-create
```jsonc
{
  "sku": "ITM-042",                       // always required
  "name": "Chicken Caesar",               // required on create only
  "price": 38,                            // required on create only
  "category_code": "ASP_CAT_12",          // required on create only
  "slug": "chicken-caesar",               // optional · unique (= mapper slug)

  "ingredients": [
    {
      "sku": "ING-007",                   // by SKU, not id
      "size": 120,
      "removable": true,                  // note: "removable", NOT "is_removable"
      "sort_order": 1,
      "replacements": [
        { "sku": "ING-009", "size": 120, "extra_charge": 3, "sort_order": 1 }
      ]
    }
  ],

  "addons": [
    { "sku": "ITM-088", "sort_order": 1 }
  ]
}
```

- Field left out or `null` → **keeps** the stored value (partial push = partial update).
- `[]` → **clears** that part · `null` → keeps it.
- Unknown fields are dropped silently.

**Responses are always HTTP 200 — read the body**
```jsonc
// stored
{ "status": true, "message": "item has been stored success" }

// invalid
{ "status": false, "message": "<first error>", "errors": { "price": ["..."] } }

// simulate — validated, rolled back, nothing stored
{
  "status": true,
  "message": "payload is valid, nothing was stored",
  "action": "create",        // or "update" — whether this sku already exists
  "payload": { },            // validated payload, ready to POST to item-creation
  "item": { }                // preview with recipe, replacements, add-ons
}
```

Flow: **simulate** → fix `errors` → POST the returned `payload` to **item-creation**.

---

## 10 · Reporting — known, not bugs

- An add-on is reported as **its own item** (Items DSR, branch item report, top-items chart) — never folded into its meal.
- A customised meal reports **above** its menu price (extra charge + surcharge are inside the stored line price).
- A removal never lowers the reported value.

---

## 11 · Reference

**New endpoints**
```
GET  /pos/items/view/{id}
GET  /dashboard/items/replacement-candidates
GET  /dashboard/items/add-ons
GET  /dashboard/ingredient-categories/list
GET  /dashboard/ingredient-categories/view/{id}
POST /dashboard/ingredient-categories/create
PUT  /dashboard/ingredient-categories/update
POST /dashboard/ingredient-categories/delete
POST /pos/items/cc/item-creation
POST /pos/items/cc/item-creation-simulate
```

**Removed** — `GET /dashboard/items/default-components`

**New fields** — `items.has_replacement|has_addon|created_from` · `ingredients.sku` (unique) `|ingredient_category_id` · recipe line `is_removable|sort_order`, `size` now decimal · `order_items.type|parent_order_item_id`

**New tables** — `ingredient_categories` · `item_ingredient_replacements` · `item_addons` · `order_item_ingredients` (per-line recipe snapshot)
