# Доработка товара — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Удалить логику «под заказ», сделать quantity и categoryId обязательными, скрыть «Не указано» из UI каталога и формы товара.

**Architecture:** Миграция БД → серверная валидация → клиентская админка → каталог/фильтры → типы/API. Сервер-first, затем клиент.

**Tech Stack:** Prisma (SQLite), Fastify (ajv schema), React + MUI + react-hook-form, axios

---

### Task 1: Prisma migration — удалить inStock и leadTimeDays

**Files:**
- Modify: `server/prisma/schema.prisma`

- [ ] **Step 1: Удалить поля inStock и leadTimeDays из модели Product**

Открыть `server/prisma/schema.prisma` и удалить строки 33-34:

```prisma
  inStock     Boolean  @default(true)
  leadTimeDays Int?
```

Перед этим — миграция данных: все товары с `inStock = false` должны получить `quantity = 0`.
Создаём миграцию с raw SQL:

```bash
cd server
npx prisma migrate dev --name remove_instock_leadtime
```

Prisma автоматически создаст migration файл. Нужно убедиться, что migration содержит:
```sql
-- Перед удалением колонок, установить quantity = 0 для товаров под заказ
UPDATE Product SET quantity = 0 WHERE inStock = 0;
```

Если Prisma не добавит это автоматически, нужно отредактировать созданный migration файл в `server/prisma/migrations/<timestamp>_remove_instock_leadtime/migration.sql`:

```sql
UPDATE Product SET quantity = 0 WHERE inStock = 0;
ALTER TABLE Product DROP COLUMN inStock;
ALTER TABLE Product DROP COLUMN leadTimeDays;
```

- [ ] **Step 2: Применить миграцию**

```bash
cd server
npx prisma migrate dev
```

Expected: Migration applied successfully, no errors.

- [ ] **Step 3: Перегенерировать Prisma Client**

```bash
cd server
npx prisma generate
```

Expected: Generated Prisma Client output.

---

### Task 2: Сервер — обновить JSON Schema и валидацию CREATE

**Files:**
- Modify: `server/src/routes/api/admin-products.js`

- [ ] **Step 1: Обновить CREATE_PRODUCT_SCHEMA (строки 11-31)**

Заменить schema на:

```javascript
const CREATE_PRODUCT_SCHEMA = {
  body: {
    type: 'object',
    required: ['title', 'priceCents', 'quantity', 'categoryId'],
    properties: {
      title: { type: 'string', minLength: 1 },
      slug: { type: 'string' },
      categoryId: { type: 'string', minLength: 1 },
      priceCents: { type: 'number', minimum: 0 },
      quantity: { type: 'number', minimum: 0 },
      shortDescription: { type: 'string', nullable: true },
      description: { type: 'string', nullable: true },
      materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] },
      imageUrl: { type: 'string', nullable: true },
      imageUrls: { type: 'array', items: { type: 'string' } },
      published: { type: 'boolean' },
    },
  },
}
```

Изменения:
- Удалены: `inStock`, `leadTimeDays`
- `quantity` — убран `nullable: true`
- `categoryId` — добавлен `minLength: 1`
- `required` массив теперь включает `'quantity'` и `'categoryId'`

- [ ] **Step 2: Обновить логику CREATE handler (строки 93-179)**

Заменить handler на:

```javascript
fastify.post(
  '/api/admin/products',
  { preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA },
  async (request, reply) => {
    const body = request.body ?? {}
    const title = String(body.title ?? '').trim()
    if (!title) {
      reply.code(400).send({ error: 'Укажите название' })
      return
    }
    const slug = String(body.slug ?? '').trim() || request.server.slugify(title) || `item-${Date.now()}`
    const categoryId = String(body.categoryId ?? '').trim()
    if (!categoryId) {
      reply.code(400).send({ error: 'Укажите категорию' })
      return
    }
    const cat = await prisma.category.findUnique({ where: { id: categoryId } })
    if (!cat) {
      reply.code(400).send({ error: 'Категория не найдена' })
      return
    }
    const priceCents = Number(body.priceCents)
    if (!Number.isFinite(priceCents) || priceCents < 0) {
      reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
      return
    }
    const exists = await prisma.product.findUnique({ where: { slug } })
    if (exists) {
      reply.code(409).send({ error: 'Такой slug уже занят' })
      return
    }

    const n = Number(body.quantity)
    if (!Number.isFinite(n) || n < 0) {
      reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
      return
    }
    const quantity = Math.floor(n)

    const product = await prisma.product.create({
      data: {
        title,
        slug,
        shortDescription: body.shortDescription ? String(body.shortDescription) : null,
        description: body.description ? String(body.description) : null,
        quantity,
        materials: JSON.stringify(request.server.parseMaterialsInput(body.materials)),
        priceCents: Math.round(priceCents),
        imageUrl: body.imageUrl ? String(body.imageUrl) : null,
        published: Boolean(body.published),
        categoryId,
        images: Array.isArray(body.imageUrls)
          ? {
              create: body.imageUrls
                .map((u) => String(u || '').trim())
                .filter(Boolean)
                .slice(0, 10)
                .map((u, idx) => ({ url: u, sort: idx })),
            }
          : undefined,
      },
      include: { category: true, images: { orderBy: { sort: 'asc' } } },
    })
    reply.code(201).send(request.server.mapProductForApi(product))
  },
)
```

Удалён import `getOrCreateUnspecifiedCategory` если он больше не используется в этом файле (проверить после PATCH handler).

---

### Task 3: Сервер — обновить PATCH handler

**Files:**
- Modify: `server/src/routes/api/admin-products.js`

- [ ] **Step 1: Обновить PATCH_PRODUCT_SCHEMA (строки 33-52)**

Заменить на:

```javascript
const PATCH_PRODUCT_SCHEMA = {
  body: {
    type: 'object',
    properties: {
      title: { type: 'string', minLength: 1 },
      slug: { type: 'string' },
      categoryId: { type: 'string', minLength: 1 },
      priceCents: { type: 'number', minimum: 0 },
      quantity: { type: 'number', minimum: 0 },
      shortDescription: { type: 'string', nullable: true },
      description: { type: 'string', nullable: true },
      materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] },
      imageUrl: { type: 'string', nullable: true },
      imageUrls: { type: 'array', items: { type: 'string' } },
      published: { type: 'boolean' },
    },
  },
}
```

Изменения:
- Удалены: `inStock`, `leadTimeDays`
- `quantity` — убран `nullable: true`
- `categoryId` — добавлен `minLength: 1`

- [ ] **Step 2: Обновить PATCH handler (строки 181-299)**

Заменить handler на:

```javascript
fastify.patch(
  '/api/admin/products/:id',
  { preHandler: [fastify.verifyAdmin], schema: PATCH_PRODUCT_SCHEMA },
  async (request, reply) => {
    const { id } = request.params
    const body = request.body ?? {}
    const existing = await prisma.product.findUnique({ where: { id } })
    if (!existing) {
      reply.code(404).send({ error: 'Товар не найден' })
      return
    }
    const data = {}
    if (body.title !== undefined) data.title = String(body.title).trim()
    if (body.slug !== undefined) {
      const s = String(body.slug).trim()
      if (s && s !== existing.slug) {
        const clash = await prisma.product.findFirst({ where: { slug: s, NOT: { id } } })
        if (clash) {
          reply.code(409).send({ error: 'Такой slug уже занят' })
          return
        }
        data.slug = s
      }
    }
    if (body.shortDescription !== undefined) {
      data.shortDescription = body.shortDescription ? String(body.shortDescription) : null
    }
    if (body.description !== undefined) {
      data.description = body.description ? String(body.description) : null
    }
    if (body.quantity !== undefined) {
      const n = Number(body.quantity)
      if (!Number.isFinite(n) || n < 0) {
        reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
        return
      }
      data.quantity = Math.floor(n)
    }
    if (body.materials !== undefined) {
      data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials))
    }
    if (body.priceCents !== undefined) {
      const p = Number(body.priceCents)
      if (!Number.isFinite(p) || p < 0) {
        reply.code(400).send({ error: 'Некорректная цена' })
        return
      }
      data.priceCents = Math.round(p)
    }
    if (body.imageUrl !== undefined) {
      data.imageUrl = body.imageUrl ? String(body.imageUrl) : null
    }
    if (body.published !== undefined) data.published = Boolean(body.published)
    if (body.categoryId !== undefined) {
      const cid = String(body.categoryId).trim()
      if (!cid) {
        reply.code(400).send({ error: 'Укажите категорию' })
        return
      }
      const cat = await prisma.category.findUnique({ where: { id: cid } })
      if (!cat) {
        reply.code(400).send({ error: 'Категория не найдена' })
        return
      }
      data.categoryId = cid
    }

    const imagesUpdate =
      body.imageUrls !== undefined
        ? {
            deleteMany: {},
            create: Array.isArray(body.imageUrls)
              ? body.imageUrls
                  .map((u) => String(u || '').trim())
                  .filter(Boolean)
                  .slice(0, 10)
                  .map((u, idx) => ({ url: u, sort: idx }))
              : [],
          }
        : undefined

    const product = await prisma.product.update({
      where: { id },
      data: { ...data, images: imagesUpdate },
      include: { category: true, images: { orderBy: { sort: 'asc' } } },
    })
    return request.server.mapProductForApi(product)
  },
)
```

- [ ] **Step 3: Удалить неиспользуемый import**

В начале файла удалить строку:
```javascript
import { getOrCreateUnspecifiedCategory } from '../../lib/default-category.js'
```

---

### Task 4: Сервер — убрать фильтр availability из public-catalog

**Files:**
- Modify: `server/src/routes/api/public-catalog.js`

- [ ] **Step 1: Удалить availability из PUBLIC_PRODUCTS_QUERY_SCHEMA (строки 3-17)**

Заменить schema на:

```javascript
const PUBLIC_PRODUCTS_QUERY_SCHEMA = {
  querystring: {
    type: 'object',
    properties: {
      categorySlug: { type: 'string' },
      q: { type: 'string' },
      sort: { type: 'string', enum: ['', 'price_asc', 'price_desc'] },
      page: { type: 'integer', minimum: 1 },
      pageSize: { type: 'integer', minimum: 1, maximum: 100 },
      priceMin: { type: 'number', minimum: 0 },
      priceMax: { type: 'number', minimum: 0 },
    },
  },
}
```

Удалена строка: `availability: { type: 'string', enum: ['all', 'in_stock', 'made_to_order'] }`

- [ ] **Step 2: Удалить логику availability из GET /api/products handler (строки 82-159)**

Удалить строки 87-88:
```javascript
const availabilityRaw = request.query?.availability
const availability = typeof availabilityRaw === 'string' ? availabilityRaw.trim() : ''
```

Удалить строки 116-123 (весь блок if/else if для availability):
```javascript
if (availability === 'in_stock') {
  where.inStock = true
  where.quantity = { gt: 0 }
} else if (availability === 'made_to_order') {
  where.inStock = false
} else if (availability && availability !== 'all') {
  return reply.code(400).send({ error: 'availability должен быть all | in_stock | made_to_order' })
}
```

---

### Task 5: Клиент — обновить типы Product

**Files:**
- Modify: `client/src/entities/product/model/types.ts`

- [ ] **Step 1: Удалить inStock и leadTimeDays из типа Product**

Заменить тип Product на:

```typescript
export type Product = {
  id: string
  title: string
  slug: string
  shortDescription: string | null
  description: string | null
  quantity: number
  materials?: string[]
  priceCents: number
  imageUrl: string | null
  imageUrls?: string[]
  published: boolean
  categoryId: string
  createdAt: string
  updatedAt: string
  category?: Category
  images?: { id: string; url: string; sort: number }[]
  reviewsSummary?: ProductReviewsSummary | null
}
```

Удалены поля: `inStock: boolean` и `leadTimeDays: number | null`

---

### Task 6: Клиент — обновить API layer

**Files:**
- Modify: `client/src/entities/product/api/product-api.ts`

- [ ] **Step 1: Удалить availability из fetchPublicProducts**

```typescript
export async function fetchPublicProducts(params?: {
  categorySlug?: string
  q?: string
  sort?: 'price_asc' | 'price_desc' | ''
  page?: number
  pageSize?: number
  priceMinCents?: number
  priceMaxCents?: number
}): Promise<PublicProductsResponse> {
  const { data } = await apiClient.get<PublicProductsResponse>('products', {
    params: {
      categorySlug: params?.categorySlug || undefined,
      q: params?.q || undefined,
      sort: params?.sort || undefined,
      page: params?.page || undefined,
      pageSize: params?.pageSize || undefined,
      priceMin: params?.priceMinCents ?? undefined,
      priceMax: params?.priceMaxCents ?? undefined,
    },
  })
  return data
}
```

Удалены: `availability` из params type и из params object.

- [ ] **Step 2: Удалить inStock и leadTimeDays из createProduct**

```typescript
export async function createProduct(body: {
  title: string
  slug?: string
  shortDescription?: string | null
  description?: string | null
  quantity: number
  materials?: string[]
  priceCents: number
  imageUrl?: string | null
  imageUrls?: string[]
  published: boolean
  categoryId: string
}): Promise<Product> {
  const { data } = await apiClient.post<Product>('admin/products', body)
  return data
}
```

- [ ] **Step 3: Удалить inStock и leadTimeDays из updateProduct**

```typescript
export async function updateProduct(
  id: string,
  body: Partial<{
    title: string
    slug: string
    shortDescription: string | null
    description: string | null
    quantity: number
    materials: string[]
    priceCents: number
    imageUrl: string | null
    imageUrls: string[]
    published: boolean
    categoryId: string
  }>,
): Promise<Product> {
  const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
  return data
}
```

---

### Task 7: Клиент — AdminProductsPage (основная админка товаров)

**Files:**
- Modify: `client/src/pages/admin-products/ui/AdminProductsPage.tsx`

- [ ] **Step 1: Обновить FormState и emptyForm**

Удалить import Switch (если не используется elsewhere).
Заменить FormState:

```typescript
type FormState = {
  title: string
  slug: string
  shortDescription: string
  description: string
  quantity: string
  materials: string
  priceRub: string
  imageUrls: string[]
  published: boolean
  categoryId: string
}
```

Заменить emptyForm:

```typescript
const emptyForm = (): FormState => ({
  title: '',
  slug: '',
  shortDescription: '',
  description: '',
  quantity: '0',
  materials: '',
  priceRub: '',
  imageUrls: [],
  published: true,
  categoryId: '',
})
```

- [ ] **Step 2: Удалить inStockValue watch**

Удалить строку:
```typescript
const inStockValue = productForm.watch('inStock')
```

- [ ] **Step 3: Обновить openEdit**

```typescript
const openEdit = (p: Product) => {
  openEditDialog(p)
  const urls =
    (p.images ?? [])
      .slice()
      .sort((a, b) => a.sort - b.sort)
      .map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : [])
  productForm.reset({
    title: p.title,
    slug: p.slug,
    shortDescription: p.shortDescription ?? '',
    description: p.description ?? '',
    quantity: String(p.quantity),
    materials: (p.materials ?? []).join(', '),
    priceRub: String(p.priceCents / 100),
    imageUrls: urls,
    published: p.published,
    categoryId: p.categoryId,
  })
}
```

- [ ] **Step 4: Обновить createMut**

```typescript
const createMut = useMutation({
  mutationFn: async () => {
    const form = productForm.getValues()
    const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
    if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
    if (!form.categoryId) throw new Error('Выберите категорию')
    const qty = form.quantity.trim()
    if (!qty) throw new Error('Укажите количество')
    const qtyNum = Number(qty)
    if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество')
    const materials = form.materials
      .split(',')
      .map((x) => x.trim())
      .filter(Boolean)
    await createProduct({
      title: form.title.trim(),
      slug: form.slug.trim() || undefined,
      shortDescription: form.shortDescription.trim() || null,
      description: form.description.trim() || null,
      quantity: Math.floor(qtyNum),
      materials,
      priceCents,
      imageUrls: form.imageUrls,
      published: form.published,
      categoryId: form.categoryId,
    })
  },
  onSuccess: () => {
    void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
    closeDialog()
  },
})
```

- [ ] **Step 5: Обновить updateMut**

```typescript
const updateMut = useMutation({
  mutationFn: async () => {
    const form = productForm.getValues()
    const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
    if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
    if (!form.categoryId) throw new Error('Выберите категорию')
    const qty = form.quantity.trim()
    if (!qty) throw new Error('Укажите количество')
    const qtyNum = Number(qty)
    if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество')
    const materials = form.materials
      .split(',')
      .map((x) => x.trim())
      .filter(Boolean)
    await updateProduct(editing!.id, {
      title: form.title.trim(),
      slug: form.slug.trim(),
      shortDescription: form.shortDescription.trim() || null,
      description: form.description.trim() || null,
      quantity: Math.floor(qtyNum),
      materials,
      priceCents,
      imageUrls: form.imageUrls,
      published: form.published,
      categoryId: form.categoryId,
    })
  },
  onSuccess: () => {
    void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
    closeDialog()
  },
})
```

- [ ] **Step 6: Обновить UI — количество, категория, удалить inStock/leadTimeDays**

TextField «Количество» (строки 363-375):

```tsx
<Controller
  control={productForm.control}
  name="quantity"
  render={({ field }) => (
    <TextField
      label="Количество"
      fullWidth
      {...field}
      inputMode="numeric"
      helperText="0 = нет в наличии"
    />
  )}
/>
```

Select «Категория» (строки 472-489) — удалить MenuItem «Не указано»:

```tsx
<Controller
  control={productForm.control}
  name="categoryId"
  render={({ field }) => (
    <FormControl fullWidth error={!field.value}>
      <InputLabel id="cat-label">Категория</InputLabel>
      <Select labelId="cat-label" label="Категория" {...field}>
        {(categoriesQuery.data ?? []).map((c: Category) => (
          <MenuItem key={c.id} value={c.id}>
            {c.name}
          </MenuItem>
        ))}
      </Select>
      {!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
    </FormControl>
  )}
/>
```

Удалить Switch inStock (строки 501-510) и conditional leadTimeDays (строки 511-517).

- [ ] **Step 7: Обновить disabled кнопку сохранения**

```tsx
<Button
  variant="contained"
  onClick={handleSubmit}
  disabled={
    !titleValue.trim() ||
    !productForm.watch('categoryId') ||
    !productForm.watch('quantity').trim() ||
    createMut.isPending ||
    updateMut.isPending
  }
>
  {editing ? 'Сохранить' : 'Создать'}
</Button>
```

- [ ] **Step 8: Удалить неиспользуемые импорты**

Удалить: `Switch` из `@mui/material/Switch`

---

### Task 8: Клиент — AdminPage (унифицированная админка)

**Files:**
- Modify: `client/src/pages/admin/ui/AdminPage.tsx`

Те же изменения что и в Task 7, но для AdminPage.tsx.

- [ ] **Step 1: Обновить FormState и emptyForm**

```typescript
type FormState = {
  title: string
  slug: string
  shortDescription: string
  description: string
  quantity: string
  materials: string
  priceRub: string
  imageUrls: string[]
  published: boolean
  categoryId: string
}

const emptyForm = (): FormState => ({
  title: '',
  slug: '',
  shortDescription: '',
  description: '',
  quantity: '0',
  materials: '',
  priceRub: '',
  imageUrls: [],
  published: true,
  categoryId: '',
})
```

- [ ] **Step 2: Удалить inStockValue watch**

Удалить строку: `const inStockValue = productForm.watch('inStock')`

- [ ] **Step 3: Обновить openEdit**

```typescript
const openEdit = (p: Product) => {
  openEditDialog(p)
  const urls =
    (p.images ?? [])
      .slice()
      .sort((a, b) => a.sort - b.sort)
      .map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : [])
  productForm.reset({
    title: p.title,
    slug: p.slug,
    shortDescription: p.shortDescription ?? '',
    description: p.description ?? '',
    quantity: String(p.quantity),
    materials: (p.materials ?? []).join(', '),
    priceRub: String(p.priceCents / 100),
    imageUrls: urls,
    published: p.published,
    categoryId: p.categoryId,
  })
}
```

- [ ] **Step 4: Обновить createMut**

```typescript
const createMut = useMutation({
  mutationFn: async () => {
    const form = productForm.getValues()
    const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
    if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
    if (!form.categoryId) throw new Error('Выберите категорию')
    const qty = form.quantity.trim()
    if (!qty) throw new Error('Укажите количество')
    const qtyNum = Number(qty)
    if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество')
    const materials = form.materials
      .split(',')
      .map((x) => x.trim())
      .filter(Boolean)
    await createProduct({
      title: form.title.trim(),
      slug: form.slug.trim() || undefined,
      shortDescription: form.shortDescription.trim() || null,
      description: form.description.trim() || null,
      quantity: Math.floor(qtyNum),
      materials,
      priceCents,
      imageUrls: form.imageUrls,
      published: form.published,
      categoryId: form.categoryId,
    })
  },
  onSuccess: () => {
    void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
    closeDialog()
  },
})
```

- [ ] **Step 5: Обновить updateMut**

```typescript
const updateMut = useMutation({
  mutationFn: async () => {
    const form = productForm.getValues()
    const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
    if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
    if (!form.categoryId) throw new Error('Выберите категорию')
    const qty = form.quantity.trim()
    if (!qty) throw new Error('Укажите количество')
    const qtyNum = Number(qty)
    if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество')
    const materials = form.materials
      .split(',')
      .map((x) => x.trim())
      .filter(Boolean)
    await updateProduct(editing!.id, {
      title: form.title.trim(),
      slug: form.slug.trim(),
      shortDescription: form.shortDescription.trim() || null,
      description: form.description.trim() || null,
      quantity: Math.floor(qtyNum),
      materials,
      priceCents,
      imageUrls: form.imageUrls,
      published: form.published,
      categoryId: form.categoryId,
    })
  },
  onSuccess: () => {
    void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
    closeDialog()
  },
})
```

- [ ] **Step 6: Обновить UI — количество**

```tsx
<Controller
  control={productForm.control}
  name="quantity"
  render={({ field }) => (
    <TextField
      label="Количество"
      fullWidth
      {...field}
      inputMode="numeric"
      helperText="0 = нет в наличии"
    />
  )}
/>
```

- [ ] **Step 7: Обновить UI — категория (удалить «Не указано»)**

```tsx
<Controller
  control={productForm.control}
  name="categoryId"
  render={({ field }) => (
    <FormControl fullWidth error={!field.value}>
      <InputLabel id="cat-label">Категория</InputLabel>
      <Select labelId="cat-label" label="Категория" {...field}>
        {(categoriesQuery.data ?? []).map((c) => (
          <MenuItem key={c.id} value={c.id}>
            {c.name}
          </MenuItem>
        ))}
      </Select>
      {!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
    </FormControl>
  )}
/>
```

- [ ] **Step 8: Удалить Switch inStock и conditional leadTimeDays**

Удалить строки 654-670 (Controller inStock + conditional leadTimeDays).

- [ ] **Step 9: Обновить disabled кнопку**

```tsx
<Button
  variant="contained"
  onClick={handleSubmit}
  disabled={
    !titleValue.trim() ||
    !productForm.watch('categoryId') ||
    !productForm.watch('quantity').trim() ||
    createMut.isPending ||
    updateMut.isPending
  }
>
  {editing ? 'Сохранить' : 'Создать'}
</Button>
```

- [ ] **Step 10: Удалить неиспользуемые импорты**

Удалить: `Switch` из `@mui/material/Switch`

---

### Task 9: Клиент — ProductCard (статус по quantity)

**Files:**
- Modify: `client/src/entities/product/ui/ProductCard.tsx`

- [ ] **Step 1: Заменить stockLabel логику**

Заменить строки 47-52:

```typescript
const stockLabel =
  product.quantity > 0
    ? null
    : { label: 'Нет в наличии', color: 'default' as const }
```

Старая логика (удалить):
```typescript
const stockLabel =
  product.inStock && product.quantity === 0
    ? { label: 'Нет в наличии', color: 'default' as const }
    : !product.inStock
      ? { label: `Под заказ · ${product.leadTimeDays ?? '—'} дн.`, color: 'warning' as const }
      : null
```

---

### Task 10: Клиент — ProductPage (убрать «под заказ» UI)

**Files:**
- Modify: `client/src/pages/product/ui/ProductPage.tsx`

- [ ] **Step 1: Обновить chip статуса (строка 134)**

Заменить:
```tsx
<Chip label={p.inStock ? 'В наличии' : `Под заказ · ${p.leadTimeDays ?? '—'} дн.`} color="default" />
```

На:
```tsx
{p.quantity > 0 && <Chip label="В наличии" color="success" />}
{p.quantity === 0 && <Chip label="Нет в наличии" color="default" />}
```

- [ ] **Step 2: Обновить условие ToggleCartIcon (строка 157)**

Заменить:
```tsx
{!isAdmin && !(p.inStock && p.quantity === 0) ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
```

На:
```tsx
{!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
```

- [ ] **Step 3: Удалить alert «под заказ» (строки 159-163)**

Удалить:
```tsx
{!p.inStock && (
  <Alert severity="info">
    Этот товар изготавливается под заказ. Доставка будет после изготовления (~{p.leadTimeDays ?? '—'} дн.).
  </Alert>
)}
```

- [ ] **Step 4: Удалить неиспользуемый import Alert**

Если Alert больше не используется в файле — удалить import. (Проверить: Alert может использоваться для других целей — оставить если используется.)

---

### Task 11: Клиент — CheckoutPage (убрать made-to-order detection)

**Files:**
- Modify: `client/src/pages/checkout/ui/CheckoutPage.tsx`

- [ ] **Step 1: Удалить hasMadeToOrder (строка 84)**

Удалить:
```typescript
const hasMadeToOrder = items.some((x) => !x.product.inStock)
```

- [ ] **Step 2: Обновить hasOverLimit (строка 83)**

Заменить:
```typescript
const hasOverLimit = items.some((x) => x.qty > x.product.quantity)
```

Старая логика:
```typescript
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
```

- [ ] **Step 3: Обновить available в списке позиций (строка 108)**

Заменить:
```typescript
const available = x.product.quantity
```

Старая логика:
```typescript
const available = x.product.inStock ? x.product.quantity : 1
```

- [ ] **Step 4: Удалить alert made-to-order (строки 130-134)**

Удалить:
```tsx
{hasMadeToOrder && (
  <Alert severity="info">
    В заказе есть товары «под заказ». Доставка будет после изготовления (срок указан в карточке товара).
  </Alert>
)}
```

---

### Task 12: Клиент — use-product-filters (убрать availability)

**Files:**
- Modify: `client/src/pages/home/lib/use-product-filters.ts`

- [ ] **Step 1: Удалить availability state и handler**

```typescript
export function useProductFilters() {
  const [categorySlug, setCategorySlug] = useState<string>('')
  const [qInput, setQInput] = useState('')
  const [q, setQ] = useState('')
  const [moreOpen, setMoreOpen] = useState(false)
  const [sort, setSort] = useState<'price_asc' | 'price_desc' | ''>('')
  const [page, setPage] = useState(1)
  const [pageSize, setPageSize] = useState(12)
  const [priceMinRub, setPriceMinRub] = useState('')
  const [priceMaxRub, setPriceMaxRub] = useState('')
  const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90)
```

Удалить: `const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')`

- [ ] **Step 2: Удалить handleAvailabilityChange**

Удалить функцию:
```typescript
const handleAvailabilityChange = (v: string) => {
  if (v === 'all' || v === 'in_stock' || v === 'made_to_order') {
    setAvailability(v)
    setPage(1)
  }
}
```

- [ ] **Step 3: Обновить resetFilters**

```typescript
const resetFilters = () => {
  setCategorySlug('')
  setQInput('')
  setSort('')
  setPriceMinRub('')
  setPriceMaxRub('')
  setPageSize(12)
  setCardScale(90)
  setMoreOpen(false)
}
```

Удалить: `setAvailability('all')`

- [ ] **Step 4: Обновить return object**

```typescript
return {
  categorySlug,
  qInput,
  q,
  moreOpen,
  sort,
  page,
  pageSize,
  priceMinRub,
  priceMaxRub,
  cardScale,
  setPage,
  setQInput,
  setMoreOpen,
  handleCategoryChange,
  handleSortChange,
  handlePageSizeChange,
  handlePriceMinChange,
  handlePriceMaxChange,
  handleCardScaleChange,
  resetFilters,
  toCents,
}
```

Удалить: `availability` и `handleAvailabilityChange`

---

### Task 13: Клиент — ProductFilters (убрать availability toggle, скрыть «Не указано»)

**Files:**
- Modify: `client/src/pages/home/ui/ProductFilters.tsx`

- [ ] **Step 1: Удалить availability из Props destructuring**

```typescript
export function ProductFilters({
  categorySlug,
  qInput,
  moreOpen,
  sort,
  pageSize,
  priceMinRub,
  priceMaxRub,
  cardScale,
  categories,
  categoriesLoading,
  setQInput,
  setMoreOpen,
  handleCategoryChange,
  handleSortChange,
  handlePageSizeChange,
  handlePriceMinChange,
  handlePriceMaxChange,
  handleCardScaleChange,
  resetFilters,
}: Props) {
```

Удалить: `availability` и `handleAvailabilityChange` из destructuring.

- [ ] **Step 2: Скрыть «Не указано» из категорий**

Обновить categoriesForFilter (строки 50-57):

```typescript
const categoriesForFilter = useMemo(() => {
  const list = (categories ?? []).filter((c) => c.slug !== 'ne-ukazano')
  return [...list].sort((a, b) => a.sort - b.sort || a.name.localeCompare(b.name, 'ru'))
}, [categories])
```

- [ ] **Step 3: Удалить ToggleButtonGroup availability (строки 128-146)**

Удалить весь блок:
```tsx
<ToggleButtonGroup
  exclusive
  size="small"
  value={availability}
  onChange={(_, v) => handleAvailabilityChange(v)}
  sx={{ ... }}
>
  <ToggleButton value="all">Все</ToggleButton>
  <ToggleButton value="in_stock">В наличии</ToggleButton>
  <ToggleButton value="made_to_order">Под заказ</ToggleButton>
</ToggleButtonGroup>
```

- [ ] **Step 4: Удалить неиспользуемые импорты**

Удалить: `ToggleButton`, `ToggleButtonGroup` из `@mui/material`

---

### Task 14: Клиент — HomePage (убрать availability из query)

**Files:**
- Modify: `client/src/pages/home/ui/HomePage.tsx`

- [ ] **Step 1: Обновить queryKey и fetchPublicProducts вызов**

```typescript
const productsQuery = useQuery({
  queryKey: [
    'products',
    'public',
    {
      categorySlug: filters.categorySlug || 'all',
      q: filters.q,
      sort: filters.sort,
      page: filters.page,
      pageSize: filters.pageSize,
      priceMinRub: filters.priceMinRub,
      priceMaxRub: filters.priceMaxRub,
    },
  ],
  queryFn: () =>
    fetchPublicProducts({
      categorySlug: filters.categorySlug || undefined,
      q: filters.q || undefined,
      sort: filters.sort || '',
      page: filters.page,
      pageSize: filters.pageSize,
      priceMinCents: filters.toCents(filters.priceMinRub),
      priceMaxCents: filters.toCents(filters.priceMaxRub),
    }),
})
```

Удалить: `availability: filters.availability` из queryKey и `availability` из fetchPublicProducts params.

- [ ] **Step 2: Обновить ToggleCartIcon в ProductCard actions**

```tsx
actions={
  !isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} /> : undefined
}
```

---

### Task 15: Запустить серверные тесты

**Files:**
- Test: `server/` tests

- [ ] **Step 1: Запустить серверные тесты**

```bash
cd server && npm test
```

Expected: All tests pass. Если есть тесты, проверяющие inStock/leadTimeDays — обновить их.

---

### Task 16: Запустить клиентские линт и тесты

**Files:**
- Test: `client/` tests

- [ ] **Step 1: Запустить линт**

```bash
cd client && npm run lint
```

Expected: No errors.

- [ ] **Step 2: Запустить форматирование**

```bash
cd client && npm run format:check
```

Expected: All files formatted correctly.

- [ ] **Step 3: Запустить тесты**

```bash
cd client && npm test
```

Expected: All tests pass.

---

### Task 17: Сборка клиента

**Files:**
- Build: `client/`

- [ ] **Step 1: Запустить сборку**

```bash
cd client && npm run build
```

Expected: Build succeeds with no TypeScript errors.

---
