Files
shop-server/.opencode/plans/2026-05-15-product-redesign-plan.md
T
2026-05-15 12:50:39 +05:00

36 KiB
Raw Blame History

Доработка товара — 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:

  inStock     Boolean  @default(true)
  leadTimeDays Int?

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

cd server
npx prisma migrate dev --name remove_instock_leadtime

Prisma автоматически создаст migration файл. Нужно убедиться, что migration содержит:

-- Перед удалением колонок, установить quantity = 0 для товаров под заказ
UPDATE Product SET quantity = 0 WHERE inStock = 0;

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

UPDATE Product SET quantity = 0 WHERE inStock = 0;
ALTER TABLE Product DROP COLUMN inStock;
ALTER TABLE Product DROP COLUMN leadTimeDays;
  • Step 2: Применить миграцию
cd server
npx prisma migrate dev

Expected: Migration applied successfully, no errors.

  • Step 3: Перегенерировать Prisma Client
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 на:

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 на:

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)

Заменить на:

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 на:

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

В начале файла удалить строку:

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 на:

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:

const availabilityRaw = request.query?.availability
const availability = typeof availabilityRaw === 'string' ? availabilityRaw.trim() : ''

Удалить строки 116-123 (весь блок if/else if для availability):

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 на:

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

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
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
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:

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

Заменить emptyForm:

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
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
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
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):

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

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

<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 кнопку сохранения
<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
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
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
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
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 — количество
<Controller
  control={productForm.control}
  name="quantity"
  render={({ field }) => (
    <TextField
      label="Количество"
      fullWidth
      {...field}
      inputMode="numeric"
      helperText="0 = нет в наличии"
    />
  )}
/>
  • Step 7: Обновить UI — категория (удалить «Не указано»)
<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 кнопку
<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:

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

Старая логика (удалить):

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)

Заменить:

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

На:

{p.quantity > 0 && <Chip label="В наличии" color="success" />}
{p.quantity === 0 && <Chip label="Нет в наличии" color="default" />}
  • Step 2: Обновить условие ToggleCartIcon (строка 157)

Заменить:

{!isAdmin && !(p.inStock && p.quantity === 0) ? <ToggleCartIcon productId={p.id} size="medium" /> : null}

На:

{!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
  • Step 3: Удалить alert «под заказ» (строки 159-163)

Удалить:

{!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)

Удалить:

const hasMadeToOrder = items.some((x) => !x.product.inStock)
  • Step 2: Обновить hasOverLimit (строка 83)

Заменить:

const hasOverLimit = items.some((x) => x.qty > x.product.quantity)

Старая логика:

const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
  • Step 3: Обновить available в списке позиций (строка 108)

Заменить:

const available = x.product.quantity

Старая логика:

const available = x.product.inStock ? x.product.quantity : 1
  • Step 4: Удалить alert made-to-order (строки 130-134)

Удалить:

{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

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

Удалить функцию:

const handleAvailabilityChange = (v: string) => {
  if (v === 'all' || v === 'in_stock' || v === 'made_to_order') {
    setAvailability(v)
    setPage(1)
  }
}
  • Step 3: Обновить resetFilters
const resetFilters = () => {
  setCategorySlug('')
  setQInput('')
  setSort('')
  setPriceMinRub('')
  setPriceMaxRub('')
  setPageSize(12)
  setCardScale(90)
  setMoreOpen(false)
}

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

  • Step 4: Обновить return object
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

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):

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)

Удалить весь блок:

<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 вызов

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
actions={
  !isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} /> : undefined
}

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

Files:

  • Test: server/ tests

  • Step 1: Запустить серверные тесты

cd server && npm test

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


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

Files:

  • Test: client/ tests

  • Step 1: Запустить линт

cd client && npm run lint

Expected: No errors.

  • Step 2: Запустить форматирование
cd client && npm run format:check

Expected: All files formatted correctly.

  • Step 3: Запустить тесты
cd client && npm test

Expected: All tests pass.


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

Files:

  • Build: client/

  • Step 1: Запустить сборку

cd client && npm run build

Expected: Build succeeds with no TypeScript errors.