Files
shop-server/.opencode/plans/2026-05-15-product-redesign-plan.md
T
2026-05-19 11:25:23 +05:00

1295 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Доработка товара — 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.
---