# Доработка товара — 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/_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 { const { data } = await apiClient.get('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 { const { data } = await apiClient.post('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 { const { data } = await apiClient.patch(`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 ( )} /> ``` Select «Категория» (строки 472-489) — удалить MenuItem «Не указано»: ```tsx ( Категория {!field.value && Выберите категорию} )} /> ``` Удалить Switch inStock (строки 501-510) и conditional leadTimeDays (строки 511-517). - [ ] **Step 7: Обновить disabled кнопку сохранения** ```tsx ``` - [ ] **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 ( )} /> ``` - [ ] **Step 7: Обновить UI — категория (удалить «Не указано»)** ```tsx ( Категория {!field.value && Выберите категорию} )} /> ``` - [ ] **Step 8: Удалить Switch inStock и conditional leadTimeDays** Удалить строки 654-670 (Controller inStock + conditional leadTimeDays). - [ ] **Step 9: Обновить disabled кнопку** ```tsx ``` - [ ] **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 ``` На: ```tsx {p.quantity > 0 && } {p.quantity === 0 && } ``` - [ ] **Step 2: Обновить условие ToggleCartIcon (строка 157)** Заменить: ```tsx {!isAdmin && !(p.inStock && p.quantity === 0) ? : null} ``` На: ```tsx {!isAdmin && p.quantity > 0 ? : null} ``` - [ ] **Step 3: Удалить alert «под заказ» (строки 159-163)** Удалить: ```tsx {!p.inStock && ( Этот товар изготавливается под заказ. Доставка будет после изготовления (~{p.leadTimeDays ?? '—'} дн.). )} ``` - [ ] **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 && ( В заказе есть товары «под заказ». Доставка будет после изготовления (срок указан в карточке товара). )} ``` --- ### 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('') 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 handleAvailabilityChange(v)} sx={{ ... }} > Все В наличии Под заказ ``` - [ ] **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 ? : 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. ---