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