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