From 89d605adf4c9de0bb7d6d49177dc11476a729458 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 15 May 2026 12:50:39 +0500 Subject: [PATCH 1/2] update goods --- .../2026-05-15-product-redesign-design.md | 174 +++ .../plans/2026-05-15-product-redesign-plan.md | 1294 +++++++++++++++++ .../1468-1778827764/state/server.pid | 1 + .../1616-1778827772/state/server.pid | 1 + .../src/entities/product/api/product-api.ts | 15 +- client/src/entities/product/model/types.ts | 2 - .../src/entities/product/ui/ProductCard.tsx | 13 +- .../toggle-cart-icon/ui/ToggleCartIcon.tsx | 2 +- .../ui/AdminCategoriesPage.tsx | 2 +- .../admin-products/ui/AdminProductsPage.tsx | 84 +- client/src/pages/admin/ui/AdminPage.tsx | 84 +- client/src/pages/cart/ui/CartPage.tsx | 6 +- client/src/pages/checkout/ui/CheckoutPage.tsx | 11 +- .../src/pages/home/lib/use-product-filters.ts | 11 - client/src/pages/home/ui/HomePage.tsx | 6 +- client/src/pages/home/ui/ProductFilters.tsx | 30 +- client/src/pages/product/ui/ProductPage.tsx | 11 +- .../migration.sql | 27 + server/prisma/schema.prisma | 2 - server/src/routes/api/admin-products.js | 113 +- server/src/routes/api/public-catalog.js | 11 - 21 files changed, 1594 insertions(+), 306 deletions(-) create mode 100644 .opencode/plans/2026-05-15-product-redesign-design.md create mode 100644 .opencode/plans/2026-05-15-product-redesign-plan.md create mode 100644 .superpowers/brainstorm/1468-1778827764/state/server.pid create mode 100644 .superpowers/brainstorm/1616-1778827772/state/server.pid create mode 100644 server/prisma/migrations/20260515000000_remove_instock_leadtime/migration.sql diff --git a/.opencode/plans/2026-05-15-product-redesign-design.md b/.opencode/plans/2026-05-15-product-redesign-design.md new file mode 100644 index 0000000..87322e0 --- /dev/null +++ b/.opencode/plans/2026-05-15-product-redesign-design.md @@ -0,0 +1,174 @@ +# Design: Доработка товара — удаление «под заказ», обязательные quantity и категория + +**Дата:** 2026-05-15 +**Статус:** На согласовании + +## Цель + +Упростить модель товара: убрать концепцию «под заказ», сделать количество и категорию обязательными полями. Категория «Не указано» остаётся технической заглушкой для переноса товаров при удалении категории, но не видна в каталоге и не выбирается при редактировании. + +## Архитектура изменений + +### 1. База данных (Prisma) + +**Миграция:** +- Перед удалением полей: все товары с `inStock = false` получают `quantity = 0` +- Удалить поля `inStock` и `leadTimeDays` из модели `Product` +- Статус наличия определяется исключительно по `quantity`: + - `quantity > 0` → «В наличии» + - `quantity = 0` → «Нет в наличии» + +**`server/prisma/schema.prisma`:** +```prisma +model Product { + // ... остальные поля без изменений ... + quantity Int @default(0) + // УДАЛЕНО: inStock Boolean @default(true) + // УДАЛЕНО: leadTimeDays Int? + category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict) + categoryId String + // ... +} +``` + +### 2. Сервер — валидация и CRUD + +**`server/src/routes/api/admin-products.js`:** + +**CREATE (POST):** +- `quantity` — required, `Int >= 0` (было nullable) +- `categoryId` — required (было: при пустом → авто-назначение «Не указано») +- Удалить валидацию `leadTimeDays` при `!inStock` +- Удалить принудительную установку `quantity = 1` для «под заказ» +- Вернуть 400: `'Укажите категорию'` если `categoryId` отсутствует + +**UPDATE (PATCH):** +- `quantity` — required, `Int >= 0` (было nullable) +- `categoryId` — required (было: при пустом → «Не указано») +- Удалить логику очистки `leadTimeDays` при `inStock = true` +- Удалить принудительную установку `quantity = 1` +- Вернуть 400 при отсутствии `categoryId` + +**JSON Schema:** +- `CREATE_PRODUCT_SCHEMA`: убрать `leadTimeDays`, сделать `quantity` required (убрать `nullable`) +- `PATCH_PRODUCT_SCHEMA`: убрать `leadTimeDays`, `quantity` — если передан, то `>= 0` + +**`server/src/routes/api/public-catalog.js`:** +- Удалить ветку `availability === 'in_stock'` и `availability === 'made_to_order'` +- Фильтрация «в наличии» больше не нужна — все товары в каталоге + +### 3. Клиент — админка (две страницы) + +**`client/src/pages/admin/ui/AdminPage.tsx`** и **`client/src/pages/admin-products/ui/AdminProductsPage.tsx`:** + +**FormState:** +- Удалить `inStock: boolean` и `leadTimeDays: string` +- `quantity: string` — без nullable-семантики + +**UI:** +- Удалить Switch «В наличии / Под заказ» +- Удалить TextField «Срок исполнения, дней» +- TextField «Количество»: + - Без helper «Оставьте пустым...» + - Новый helper: «0 = нет в наличии» + - Валидация: не может быть пустым, `parseInt >= 0` +- Select «Категория»: + - Удалить `` с «Не указано» + - Валидация: не даёт сохранить без выбранной категории + - Показать ошибку при попытке сохранить без категории + +**Submit-валидация:** +- Удалить проверку `leadTimeDays` при `!inStock` +- Добавить проверку: `categoryId` не пустой → blocking error +- Добавить проверку: `quantity` не пустой → blocking error + +### 4. Клиент — каталог + +**`client/src/entities/product/ui/ProductCard.tsx`:** +- Удалить логику `'Под заказ · {leadTimeDays} дн.'` +- Новый статус: + - `quantity > 0` → «В наличии» (зелёный) + - `quantity === 0` → «Нет в наличии» (серый/red) + +**`client/src/pages/product/ui/ProductPage.tsx`:** +- Удалить chip `'Под заказ · {leadTimeDays} дн.'` +- Удалить alert `'Этот товар изготавливается под заказ...'` +- Статус определяется по `quantity` + +**`client/src/pages/checkout/ui/CheckoutPage.tsx`:** +- Удалить определение made-to-order товаров в корзине +- Удалить info alert о доставке после изготовления + +### 5. Клиент — фильтры + +**`client/src/pages/home/lib/use-product-filters.ts`:** +- Удалить `availability: 'all' | 'in_stock' | 'made_to_order'` из state +- Удалить `availability` из параметров `fetchPublicProducts()` + +**`client/src/pages/home/ui/ProductFilters.tsx`:** +- Удалить `ToggleButtonGroup` с `'all'`, `'in_stock'`, `'made_to_order'` +- Удалить отображение категории «Не указано» из списка чипов (фильтр `cat.slug !== 'ne-ukazano'`) + +### 6. Категория «Не указано» — что остаётся + +| Где | Что происходит | +|---|---| +| `server/src/lib/default-category.js` | **Остаётся** — функция `getOrCreateUnspecifiedCategory()` | +| `server/src/index.js` | **Остаётся** — вызов при старте | +| `server/src/routes/api/admin-categories.js` | **Остаётся** — нельзя удалить/переименовать; при удалении категории товары переезжают в «Не указано» | +| Админка категорий | **Остаётся** — кнопка удаления заблокирована | +| Фильтры каталога | **Скрыта** — не показывается в чипах | +| Форма товара | **Скрыта** — не выбирается в Select | + +## Статус товара — новая логика + +``` +quantity > 0 → «В наличии» (зелёный chip/badge) +quantity = 0 → «Нет в наличии» (серый chip/badge) +``` + +Никаких других статусов. Поле `inStock` больше не существует. + +## Файлы для изменения + +### Сервер +| Файл | Изменения | +|---|---| +| `server/prisma/schema.prisma` | Удалить `inStock`, `leadTimeDays` | +| `server/src/routes/api/admin-products.js` | Валидация, schema, убрать логику под заказ | +| `server/src/routes/api/public-catalog.js` | Убрать фильтр availability | + +### Клиент +| Файл | Изменения | +|---|---| +| `client/src/pages/admin/ui/AdminPage.tsx` | FormState, UI, валидация | +| `client/src/pages/admin-products/ui/AdminProductsPage.tsx` | FormState, UI, валидация | +| `client/src/entities/product/ui/ProductCard.tsx` | Статус по quantity | +| `client/src/pages/product/ui/ProductPage.tsx` | Убрать под заказ UI | +| `client/src/pages/checkout/ui/CheckoutPage.tsx` | Убрать made-to-order detection | +| `client/src/pages/home/ui/ProductFilters.tsx` | Убрать availability toggle, скрыть «Не указано» | +| `client/src/pages/home/lib/use-product-filters.ts` | Убрать `availability` | + +## Миграция данных + +```javascript +// В Prisma migration: +// 1. UPDATE Product SET quantity = 0 WHERE inStock = false +// 2. ALTER TABLE Product DROP COLUMN inStock +// 3. ALTER TABLE Product DROP COLUMN leadTimeDays +``` + +## Тестирование + +**Сервер:** +- CREATE без categoryId → 400 +- CREATE без quantity → 400 +- CREATE с quantity = 0 → OK +- PATCH без categoryId → 400 +- PATCH с quantity = 0 → OK + +**Клиент:** +- Форма не сохраняется без категории +- Форма не сохраняется без количества +- Фильтры не содержат «Под заказ» и «Не указано» +- Карточка товара показывает «Нет в наличии» при quantity = 0 diff --git a/.opencode/plans/2026-05-15-product-redesign-plan.md b/.opencode/plans/2026-05-15-product-redesign-plan.md new file mode 100644 index 0000000..672871c --- /dev/null +++ b/.opencode/plans/2026-05-15-product-redesign-plan.md @@ -0,0 +1,1294 @@ +# Доработка товара — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Удалить логику «под заказ», сделать quantity и categoryId обязательными, скрыть «Не указано» из UI каталога и формы товара. + +**Architecture:** Миграция БД → серверная валидация → клиентская админка → каталог/фильтры → типы/API. Сервер-first, затем клиент. + +**Tech Stack:** Prisma (SQLite), Fastify (ajv schema), React + MUI + react-hook-form, axios + +--- + +### Task 1: Prisma migration — удалить inStock и leadTimeDays + +**Files:** +- Modify: `server/prisma/schema.prisma` + +- [ ] **Step 1: Удалить поля inStock и leadTimeDays из модели Product** + +Открыть `server/prisma/schema.prisma` и удалить строки 33-34: + +```prisma + inStock Boolean @default(true) + leadTimeDays Int? +``` + +Перед этим — миграция данных: все товары с `inStock = false` должны получить `quantity = 0`. +Создаём миграцию с raw SQL: + +```bash +cd server +npx prisma migrate dev --name remove_instock_leadtime +``` + +Prisma автоматически создаст migration файл. Нужно убедиться, что migration содержит: +```sql +-- Перед удалением колонок, установить quantity = 0 для товаров под заказ +UPDATE Product SET quantity = 0 WHERE inStock = 0; +``` + +Если Prisma не добавит это автоматически, нужно отредактировать созданный migration файл в `server/prisma/migrations/_remove_instock_leadtime/migration.sql`: + +```sql +UPDATE Product SET quantity = 0 WHERE inStock = 0; +ALTER TABLE Product DROP COLUMN inStock; +ALTER TABLE Product DROP COLUMN leadTimeDays; +``` + +- [ ] **Step 2: Применить миграцию** + +```bash +cd server +npx prisma migrate dev +``` + +Expected: Migration applied successfully, no errors. + +- [ ] **Step 3: Перегенерировать Prisma Client** + +```bash +cd server +npx prisma generate +``` + +Expected: Generated Prisma Client output. + +--- + +### Task 2: Сервер — обновить JSON Schema и валидацию CREATE + +**Files:** +- Modify: `server/src/routes/api/admin-products.js` + +- [ ] **Step 1: Обновить CREATE_PRODUCT_SCHEMA (строки 11-31)** + +Заменить schema на: + +```javascript +const CREATE_PRODUCT_SCHEMA = { + body: { + type: 'object', + required: ['title', 'priceCents', 'quantity', 'categoryId'], + properties: { + title: { type: 'string', minLength: 1 }, + slug: { type: 'string' }, + categoryId: { type: 'string', minLength: 1 }, + priceCents: { type: 'number', minimum: 0 }, + quantity: { type: 'number', minimum: 0 }, + shortDescription: { type: 'string', nullable: true }, + description: { type: 'string', nullable: true }, + materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, + imageUrl: { type: 'string', nullable: true }, + imageUrls: { type: 'array', items: { type: 'string' } }, + published: { type: 'boolean' }, + }, + }, +} +``` + +Изменения: +- Удалены: `inStock`, `leadTimeDays` +- `quantity` — убран `nullable: true` +- `categoryId` — добавлен `minLength: 1` +- `required` массив теперь включает `'quantity'` и `'categoryId'` + +- [ ] **Step 2: Обновить логику CREATE handler (строки 93-179)** + +Заменить handler на: + +```javascript +fastify.post( + '/api/admin/products', + { preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA }, + async (request, reply) => { + const body = request.body ?? {} + const title = String(body.title ?? '').trim() + if (!title) { + reply.code(400).send({ error: 'Укажите название' }) + return + } + const slug = String(body.slug ?? '').trim() || request.server.slugify(title) || `item-${Date.now()}` + const categoryId = String(body.categoryId ?? '').trim() + if (!categoryId) { + reply.code(400).send({ error: 'Укажите категорию' }) + return + } + const cat = await prisma.category.findUnique({ where: { id: categoryId } }) + if (!cat) { + reply.code(400).send({ error: 'Категория не найдена' }) + return + } + const priceCents = Number(body.priceCents) + if (!Number.isFinite(priceCents) || priceCents < 0) { + reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' }) + return + } + const exists = await prisma.product.findUnique({ where: { slug } }) + if (exists) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + + const n = Number(body.quantity) + if (!Number.isFinite(n) || n < 0) { + reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) + return + } + const quantity = Math.floor(n) + + const product = await prisma.product.create({ + data: { + title, + slug, + shortDescription: body.shortDescription ? String(body.shortDescription) : null, + description: body.description ? String(body.description) : null, + quantity, + materials: JSON.stringify(request.server.parseMaterialsInput(body.materials)), + priceCents: Math.round(priceCents), + imageUrl: body.imageUrl ? String(body.imageUrl) : null, + published: Boolean(body.published), + categoryId, + images: Array.isArray(body.imageUrls) + ? { + create: body.imageUrls + .map((u) => String(u || '').trim()) + .filter(Boolean) + .slice(0, 10) + .map((u, idx) => ({ url: u, sort: idx })), + } + : undefined, + }, + include: { category: true, images: { orderBy: { sort: 'asc' } } }, + }) + reply.code(201).send(request.server.mapProductForApi(product)) + }, +) +``` + +Удалён import `getOrCreateUnspecifiedCategory` если он больше не используется в этом файле (проверить после PATCH handler). + +--- + +### Task 3: Сервер — обновить PATCH handler + +**Files:** +- Modify: `server/src/routes/api/admin-products.js` + +- [ ] **Step 1: Обновить PATCH_PRODUCT_SCHEMA (строки 33-52)** + +Заменить на: + +```javascript +const PATCH_PRODUCT_SCHEMA = { + body: { + type: 'object', + properties: { + title: { type: 'string', minLength: 1 }, + slug: { type: 'string' }, + categoryId: { type: 'string', minLength: 1 }, + priceCents: { type: 'number', minimum: 0 }, + quantity: { type: 'number', minimum: 0 }, + shortDescription: { type: 'string', nullable: true }, + description: { type: 'string', nullable: true }, + materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, + imageUrl: { type: 'string', nullable: true }, + imageUrls: { type: 'array', items: { type: 'string' } }, + published: { type: 'boolean' }, + }, + }, +} +``` + +Изменения: +- Удалены: `inStock`, `leadTimeDays` +- `quantity` — убран `nullable: true` +- `categoryId` — добавлен `minLength: 1` + +- [ ] **Step 2: Обновить PATCH handler (строки 181-299)** + +Заменить handler на: + +```javascript +fastify.patch( + '/api/admin/products/:id', + { preHandler: [fastify.verifyAdmin], schema: PATCH_PRODUCT_SCHEMA }, + async (request, reply) => { + const { id } = request.params + const body = request.body ?? {} + const existing = await prisma.product.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Товар не найден' }) + return + } + const data = {} + if (body.title !== undefined) data.title = String(body.title).trim() + if (body.slug !== undefined) { + const s = String(body.slug).trim() + if (s && s !== existing.slug) { + const clash = await prisma.product.findFirst({ where: { slug: s, NOT: { id } } }) + if (clash) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + data.slug = s + } + } + if (body.shortDescription !== undefined) { + data.shortDescription = body.shortDescription ? String(body.shortDescription) : null + } + if (body.description !== undefined) { + data.description = body.description ? String(body.description) : null + } + if (body.quantity !== undefined) { + const n = Number(body.quantity) + if (!Number.isFinite(n) || n < 0) { + reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) + return + } + data.quantity = Math.floor(n) + } + if (body.materials !== undefined) { + data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials)) + } + if (body.priceCents !== undefined) { + const p = Number(body.priceCents) + if (!Number.isFinite(p) || p < 0) { + reply.code(400).send({ error: 'Некорректная цена' }) + return + } + data.priceCents = Math.round(p) + } + if (body.imageUrl !== undefined) { + data.imageUrl = body.imageUrl ? String(body.imageUrl) : null + } + if (body.published !== undefined) data.published = Boolean(body.published) + if (body.categoryId !== undefined) { + const cid = String(body.categoryId).trim() + if (!cid) { + reply.code(400).send({ error: 'Укажите категорию' }) + return + } + const cat = await prisma.category.findUnique({ where: { id: cid } }) + if (!cat) { + reply.code(400).send({ error: 'Категория не найдена' }) + return + } + data.categoryId = cid + } + + const imagesUpdate = + body.imageUrls !== undefined + ? { + deleteMany: {}, + create: Array.isArray(body.imageUrls) + ? body.imageUrls + .map((u) => String(u || '').trim()) + .filter(Boolean) + .slice(0, 10) + .map((u, idx) => ({ url: u, sort: idx })) + : [], + } + : undefined + + const product = await prisma.product.update({ + where: { id }, + data: { ...data, images: imagesUpdate }, + include: { category: true, images: { orderBy: { sort: 'asc' } } }, + }) + return request.server.mapProductForApi(product) + }, +) +``` + +- [ ] **Step 3: Удалить неиспользуемый import** + +В начале файла удалить строку: +```javascript +import { getOrCreateUnspecifiedCategory } from '../../lib/default-category.js' +``` + +--- + +### Task 4: Сервер — убрать фильтр availability из public-catalog + +**Files:** +- Modify: `server/src/routes/api/public-catalog.js` + +- [ ] **Step 1: Удалить availability из PUBLIC_PRODUCTS_QUERY_SCHEMA (строки 3-17)** + +Заменить schema на: + +```javascript +const PUBLIC_PRODUCTS_QUERY_SCHEMA = { + querystring: { + type: 'object', + properties: { + categorySlug: { type: 'string' }, + q: { type: 'string' }, + sort: { type: 'string', enum: ['', 'price_asc', 'price_desc'] }, + page: { type: 'integer', minimum: 1 }, + pageSize: { type: 'integer', minimum: 1, maximum: 100 }, + priceMin: { type: 'number', minimum: 0 }, + priceMax: { type: 'number', minimum: 0 }, + }, + }, +} +``` + +Удалена строка: `availability: { type: 'string', enum: ['all', 'in_stock', 'made_to_order'] }` + +- [ ] **Step 2: Удалить логику availability из GET /api/products handler (строки 82-159)** + +Удалить строки 87-88: +```javascript +const availabilityRaw = request.query?.availability +const availability = typeof availabilityRaw === 'string' ? availabilityRaw.trim() : '' +``` + +Удалить строки 116-123 (весь блок if/else if для availability): +```javascript +if (availability === 'in_stock') { + where.inStock = true + where.quantity = { gt: 0 } +} else if (availability === 'made_to_order') { + where.inStock = false +} else if (availability && availability !== 'all') { + return reply.code(400).send({ error: 'availability должен быть all | in_stock | made_to_order' }) +} +``` + +--- + +### Task 5: Клиент — обновить типы Product + +**Files:** +- Modify: `client/src/entities/product/model/types.ts` + +- [ ] **Step 1: Удалить inStock и leadTimeDays из типа Product** + +Заменить тип Product на: + +```typescript +export type Product = { + id: string + title: string + slug: string + shortDescription: string | null + description: string | null + quantity: number + materials?: string[] + priceCents: number + imageUrl: string | null + imageUrls?: string[] + published: boolean + categoryId: string + createdAt: string + updatedAt: string + category?: Category + images?: { id: string; url: string; sort: number }[] + reviewsSummary?: ProductReviewsSummary | null +} +``` + +Удалены поля: `inStock: boolean` и `leadTimeDays: number | null` + +--- + +### Task 6: Клиент — обновить API layer + +**Files:** +- Modify: `client/src/entities/product/api/product-api.ts` + +- [ ] **Step 1: Удалить availability из fetchPublicProducts** + +```typescript +export async function fetchPublicProducts(params?: { + categorySlug?: string + q?: string + sort?: 'price_asc' | 'price_desc' | '' + page?: number + pageSize?: number + priceMinCents?: number + priceMaxCents?: number +}): Promise { + const { data } = await apiClient.get('products', { + params: { + categorySlug: params?.categorySlug || undefined, + q: params?.q || undefined, + sort: params?.sort || undefined, + page: params?.page || undefined, + pageSize: params?.pageSize || undefined, + priceMin: params?.priceMinCents ?? undefined, + priceMax: params?.priceMaxCents ?? undefined, + }, + }) + return data +} +``` + +Удалены: `availability` из params type и из params object. + +- [ ] **Step 2: Удалить inStock и leadTimeDays из createProduct** + +```typescript +export async function createProduct(body: { + title: string + slug?: string + shortDescription?: string | null + description?: string | null + quantity: number + materials?: string[] + priceCents: number + imageUrl?: string | null + imageUrls?: string[] + published: boolean + categoryId: string +}): Promise { + const { data } = await apiClient.post('admin/products', body) + return data +} +``` + +- [ ] **Step 3: Удалить inStock и leadTimeDays из updateProduct** + +```typescript +export async function updateProduct( + id: string, + body: Partial<{ + title: string + slug: string + shortDescription: string | null + description: string | null + quantity: number + materials: string[] + priceCents: number + imageUrl: string | null + imageUrls: string[] + published: boolean + categoryId: string + }>, +): Promise { + const { data } = await apiClient.patch(`admin/products/${id}`, body) + return data +} +``` + +--- + +### Task 7: Клиент — AdminProductsPage (основная админка товаров) + +**Files:** +- Modify: `client/src/pages/admin-products/ui/AdminProductsPage.tsx` + +- [ ] **Step 1: Обновить FormState и emptyForm** + +Удалить import Switch (если не используется elsewhere). +Заменить FormState: + +```typescript +type FormState = { + title: string + slug: string + shortDescription: string + description: string + quantity: string + materials: string + priceRub: string + imageUrls: string[] + published: boolean + categoryId: string +} +``` + +Заменить emptyForm: + +```typescript +const emptyForm = (): FormState => ({ + title: '', + slug: '', + shortDescription: '', + description: '', + quantity: '0', + materials: '', + priceRub: '', + imageUrls: [], + published: true, + categoryId: '', +}) +``` + +- [ ] **Step 2: Удалить inStockValue watch** + +Удалить строку: +```typescript +const inStockValue = productForm.watch('inStock') +``` + +- [ ] **Step 3: Обновить openEdit** + +```typescript +const openEdit = (p: Product) => { + openEditDialog(p) + const urls = + (p.images ?? []) + .slice() + .sort((a, b) => a.sort - b.sort) + .map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : []) + productForm.reset({ + title: p.title, + slug: p.slug, + shortDescription: p.shortDescription ?? '', + description: p.description ?? '', + quantity: String(p.quantity), + materials: (p.materials ?? []).join(', '), + priceRub: String(p.priceCents / 100), + imageUrls: urls, + published: p.published, + categoryId: p.categoryId, + }) +} +``` + +- [ ] **Step 4: Обновить createMut** + +```typescript +const createMut = useMutation({ + mutationFn: async () => { + const form = productForm.getValues() + const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) + if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') + if (!form.categoryId) throw new Error('Выберите категорию') + const qty = form.quantity.trim() + if (!qty) throw new Error('Укажите количество') + const qtyNum = Number(qty) + if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') + const materials = form.materials + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + await createProduct({ + title: form.title.trim(), + slug: form.slug.trim() || undefined, + shortDescription: form.shortDescription.trim() || null, + description: form.description.trim() || null, + quantity: Math.floor(qtyNum), + materials, + priceCents, + imageUrls: form.imageUrls, + published: form.published, + categoryId: form.categoryId, + }) + }, + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) + closeDialog() + }, +}) +``` + +- [ ] **Step 5: Обновить updateMut** + +```typescript +const updateMut = useMutation({ + mutationFn: async () => { + const form = productForm.getValues() + const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) + if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') + if (!form.categoryId) throw new Error('Выберите категорию') + const qty = form.quantity.trim() + if (!qty) throw new Error('Укажите количество') + const qtyNum = Number(qty) + if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') + const materials = form.materials + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + await updateProduct(editing!.id, { + title: form.title.trim(), + slug: form.slug.trim(), + shortDescription: form.shortDescription.trim() || null, + description: form.description.trim() || null, + quantity: Math.floor(qtyNum), + materials, + priceCents, + imageUrls: form.imageUrls, + published: form.published, + categoryId: form.categoryId, + }) + }, + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) + closeDialog() + }, +}) +``` + +- [ ] **Step 6: Обновить UI — количество, категория, удалить inStock/leadTimeDays** + +TextField «Количество» (строки 363-375): + +```tsx + ( + + )} +/> +``` + +Select «Категория» (строки 472-489) — удалить MenuItem «Не указано»: + +```tsx + ( + + Категория + + {!field.value && Выберите категорию} + + )} +/> +``` + +Удалить Switch inStock (строки 501-510) и conditional leadTimeDays (строки 511-517). + +- [ ] **Step 7: Обновить disabled кнопку сохранения** + +```tsx + +``` + +- [ ] **Step 8: Удалить неиспользуемые импорты** + +Удалить: `Switch` из `@mui/material/Switch` + +--- + +### Task 8: Клиент — AdminPage (унифицированная админка) + +**Files:** +- Modify: `client/src/pages/admin/ui/AdminPage.tsx` + +Те же изменения что и в Task 7, но для AdminPage.tsx. + +- [ ] **Step 1: Обновить FormState и emptyForm** + +```typescript +type FormState = { + title: string + slug: string + shortDescription: string + description: string + quantity: string + materials: string + priceRub: string + imageUrls: string[] + published: boolean + categoryId: string +} + +const emptyForm = (): FormState => ({ + title: '', + slug: '', + shortDescription: '', + description: '', + quantity: '0', + materials: '', + priceRub: '', + imageUrls: [], + published: true, + categoryId: '', +}) +``` + +- [ ] **Step 2: Удалить inStockValue watch** + +Удалить строку: `const inStockValue = productForm.watch('inStock')` + +- [ ] **Step 3: Обновить openEdit** + +```typescript +const openEdit = (p: Product) => { + openEditDialog(p) + const urls = + (p.images ?? []) + .slice() + .sort((a, b) => a.sort - b.sort) + .map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : []) + productForm.reset({ + title: p.title, + slug: p.slug, + shortDescription: p.shortDescription ?? '', + description: p.description ?? '', + quantity: String(p.quantity), + materials: (p.materials ?? []).join(', '), + priceRub: String(p.priceCents / 100), + imageUrls: urls, + published: p.published, + categoryId: p.categoryId, + }) +} +``` + +- [ ] **Step 4: Обновить createMut** + +```typescript +const createMut = useMutation({ + mutationFn: async () => { + const form = productForm.getValues() + const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) + if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') + if (!form.categoryId) throw new Error('Выберите категорию') + const qty = form.quantity.trim() + if (!qty) throw new Error('Укажите количество') + const qtyNum = Number(qty) + if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') + const materials = form.materials + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + await createProduct({ + title: form.title.trim(), + slug: form.slug.trim() || undefined, + shortDescription: form.shortDescription.trim() || null, + description: form.description.trim() || null, + quantity: Math.floor(qtyNum), + materials, + priceCents, + imageUrls: form.imageUrls, + published: form.published, + categoryId: form.categoryId, + }) + }, + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) + closeDialog() + }, +}) +``` + +- [ ] **Step 5: Обновить updateMut** + +```typescript +const updateMut = useMutation({ + mutationFn: async () => { + const form = productForm.getValues() + const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) + if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') + if (!form.categoryId) throw new Error('Выберите категорию') + const qty = form.quantity.trim() + if (!qty) throw new Error('Укажите количество') + const qtyNum = Number(qty) + if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') + const materials = form.materials + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + await updateProduct(editing!.id, { + title: form.title.trim(), + slug: form.slug.trim(), + shortDescription: form.shortDescription.trim() || null, + description: form.description.trim() || null, + quantity: Math.floor(qtyNum), + materials, + priceCents, + imageUrls: form.imageUrls, + published: form.published, + categoryId: form.categoryId, + }) + }, + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) + closeDialog() + }, +}) +``` + +- [ ] **Step 6: Обновить UI — количество** + +```tsx + ( + + )} +/> +``` + +- [ ] **Step 7: Обновить UI — категория (удалить «Не указано»)** + +```tsx + ( + + Категория + + {!field.value && Выберите категорию} + + )} +/> +``` + +- [ ] **Step 8: Удалить Switch inStock и conditional leadTimeDays** + +Удалить строки 654-670 (Controller inStock + conditional leadTimeDays). + +- [ ] **Step 9: Обновить disabled кнопку** + +```tsx + +``` + +- [ ] **Step 10: Удалить неиспользуемые импорты** + +Удалить: `Switch` из `@mui/material/Switch` + +--- + +### Task 9: Клиент — ProductCard (статус по quantity) + +**Files:** +- Modify: `client/src/entities/product/ui/ProductCard.tsx` + +- [ ] **Step 1: Заменить stockLabel логику** + +Заменить строки 47-52: + +```typescript +const stockLabel = + product.quantity > 0 + ? null + : { label: 'Нет в наличии', color: 'default' as const } +``` + +Старая логика (удалить): +```typescript +const stockLabel = + product.inStock && product.quantity === 0 + ? { label: 'Нет в наличии', color: 'default' as const } + : !product.inStock + ? { label: `Под заказ · ${product.leadTimeDays ?? '—'} дн.`, color: 'warning' as const } + : null +``` + +--- + +### Task 10: Клиент — ProductPage (убрать «под заказ» UI) + +**Files:** +- Modify: `client/src/pages/product/ui/ProductPage.tsx` + +- [ ] **Step 1: Обновить chip статуса (строка 134)** + +Заменить: +```tsx + +``` + +На: +```tsx +{p.quantity > 0 && } +{p.quantity === 0 && } +``` + +- [ ] **Step 2: Обновить условие ToggleCartIcon (строка 157)** + +Заменить: +```tsx +{!isAdmin && !(p.inStock && p.quantity === 0) ? : null} +``` + +На: +```tsx +{!isAdmin && p.quantity > 0 ? : null} +``` + +- [ ] **Step 3: Удалить alert «под заказ» (строки 159-163)** + +Удалить: +```tsx +{!p.inStock && ( + + Этот товар изготавливается под заказ. Доставка будет после изготовления (~{p.leadTimeDays ?? '—'} дн.). + +)} +``` + +- [ ] **Step 4: Удалить неиспользуемый import Alert** + +Если Alert больше не используется в файле — удалить import. (Проверить: Alert может использоваться для других целей — оставить если используется.) + +--- + +### Task 11: Клиент — CheckoutPage (убрать made-to-order detection) + +**Files:** +- Modify: `client/src/pages/checkout/ui/CheckoutPage.tsx` + +- [ ] **Step 1: Удалить hasMadeToOrder (строка 84)** + +Удалить: +```typescript +const hasMadeToOrder = items.some((x) => !x.product.inStock) +``` + +- [ ] **Step 2: Обновить hasOverLimit (строка 83)** + +Заменить: +```typescript +const hasOverLimit = items.some((x) => x.qty > x.product.quantity) +``` + +Старая логика: +```typescript +const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1)) +``` + +- [ ] **Step 3: Обновить available в списке позиций (строка 108)** + +Заменить: +```typescript +const available = x.product.quantity +``` + +Старая логика: +```typescript +const available = x.product.inStock ? x.product.quantity : 1 +``` + +- [ ] **Step 4: Удалить alert made-to-order (строки 130-134)** + +Удалить: +```tsx +{hasMadeToOrder && ( + + В заказе есть товары «под заказ». Доставка будет после изготовления (срок указан в карточке товара). + +)} +``` + +--- + +### Task 12: Клиент — use-product-filters (убрать availability) + +**Files:** +- Modify: `client/src/pages/home/lib/use-product-filters.ts` + +- [ ] **Step 1: Удалить availability state и handler** + +```typescript +export function useProductFilters() { + const [categorySlug, setCategorySlug] = useState('') + const [qInput, setQInput] = useState('') + const [q, setQ] = useState('') + const [moreOpen, setMoreOpen] = useState(false) + const [sort, setSort] = useState<'price_asc' | 'price_desc' | ''>('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(12) + const [priceMinRub, setPriceMinRub] = useState('') + const [priceMaxRub, setPriceMaxRub] = useState('') + const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90) +``` + +Удалить: `const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')` + +- [ ] **Step 2: Удалить handleAvailabilityChange** + +Удалить функцию: +```typescript +const handleAvailabilityChange = (v: string) => { + if (v === 'all' || v === 'in_stock' || v === 'made_to_order') { + setAvailability(v) + setPage(1) + } +} +``` + +- [ ] **Step 3: Обновить resetFilters** + +```typescript +const resetFilters = () => { + setCategorySlug('') + setQInput('') + setSort('') + setPriceMinRub('') + setPriceMaxRub('') + setPageSize(12) + setCardScale(90) + setMoreOpen(false) +} +``` + +Удалить: `setAvailability('all')` + +- [ ] **Step 4: Обновить return object** + +```typescript +return { + categorySlug, + qInput, + q, + moreOpen, + sort, + page, + pageSize, + priceMinRub, + priceMaxRub, + cardScale, + setPage, + setQInput, + setMoreOpen, + handleCategoryChange, + handleSortChange, + handlePageSizeChange, + handlePriceMinChange, + handlePriceMaxChange, + handleCardScaleChange, + resetFilters, + toCents, +} +``` + +Удалить: `availability` и `handleAvailabilityChange` + +--- + +### Task 13: Клиент — ProductFilters (убрать availability toggle, скрыть «Не указано») + +**Files:** +- Modify: `client/src/pages/home/ui/ProductFilters.tsx` + +- [ ] **Step 1: Удалить availability из Props destructuring** + +```typescript +export function ProductFilters({ + categorySlug, + qInput, + moreOpen, + sort, + pageSize, + priceMinRub, + priceMaxRub, + cardScale, + categories, + categoriesLoading, + setQInput, + setMoreOpen, + handleCategoryChange, + handleSortChange, + handlePageSizeChange, + handlePriceMinChange, + handlePriceMaxChange, + handleCardScaleChange, + resetFilters, +}: Props) { +``` + +Удалить: `availability` и `handleAvailabilityChange` из destructuring. + +- [ ] **Step 2: Скрыть «Не указано» из категорий** + +Обновить categoriesForFilter (строки 50-57): + +```typescript +const categoriesForFilter = useMemo(() => { + const list = (categories ?? []).filter((c) => c.slug !== 'ne-ukazano') + return [...list].sort((a, b) => a.sort - b.sort || a.name.localeCompare(b.name, 'ru')) +}, [categories]) +``` + +- [ ] **Step 3: Удалить ToggleButtonGroup availability (строки 128-146)** + +Удалить весь блок: +```tsx + handleAvailabilityChange(v)} + sx={{ ... }} +> + Все + В наличии + Под заказ + +``` + +- [ ] **Step 4: Удалить неиспользуемые импорты** + +Удалить: `ToggleButton`, `ToggleButtonGroup` из `@mui/material` + +--- + +### Task 14: Клиент — HomePage (убрать availability из query) + +**Files:** +- Modify: `client/src/pages/home/ui/HomePage.tsx` + +- [ ] **Step 1: Обновить queryKey и fetchPublicProducts вызов** + +```typescript +const productsQuery = useQuery({ + queryKey: [ + 'products', + 'public', + { + categorySlug: filters.categorySlug || 'all', + q: filters.q, + sort: filters.sort, + page: filters.page, + pageSize: filters.pageSize, + priceMinRub: filters.priceMinRub, + priceMaxRub: filters.priceMaxRub, + }, + ], + queryFn: () => + fetchPublicProducts({ + categorySlug: filters.categorySlug || undefined, + q: filters.q || undefined, + sort: filters.sort || '', + page: filters.page, + pageSize: filters.pageSize, + priceMinCents: filters.toCents(filters.priceMinRub), + priceMaxCents: filters.toCents(filters.priceMaxRub), + }), +}) +``` + +Удалить: `availability: filters.availability` из queryKey и `availability` из fetchPublicProducts params. + +- [ ] **Step 2: Обновить ToggleCartIcon в ProductCard actions** + +```tsx +actions={ + !isAdmin && p.quantity > 0 ? : undefined +} +``` + +--- + +### Task 15: Запустить серверные тесты + +**Files:** +- Test: `server/` tests + +- [ ] **Step 1: Запустить серверные тесты** + +```bash +cd server && npm test +``` + +Expected: All tests pass. Если есть тесты, проверяющие inStock/leadTimeDays — обновить их. + +--- + +### Task 16: Запустить клиентские линт и тесты + +**Files:** +- Test: `client/` tests + +- [ ] **Step 1: Запустить линт** + +```bash +cd client && npm run lint +``` + +Expected: No errors. + +- [ ] **Step 2: Запустить форматирование** + +```bash +cd client && npm run format:check +``` + +Expected: All files formatted correctly. + +- [ ] **Step 3: Запустить тесты** + +```bash +cd client && npm test +``` + +Expected: All tests pass. + +--- + +### Task 17: Сборка клиента + +**Files:** +- Build: `client/` + +- [ ] **Step 1: Запустить сборку** + +```bash +cd client && npm run build +``` + +Expected: Build succeeds with no TypeScript errors. + +--- diff --git a/.superpowers/brainstorm/1468-1778827764/state/server.pid b/.superpowers/brainstorm/1468-1778827764/state/server.pid new file mode 100644 index 0000000..0d4b7f5 --- /dev/null +++ b/.superpowers/brainstorm/1468-1778827764/state/server.pid @@ -0,0 +1 @@ +1476 diff --git a/.superpowers/brainstorm/1616-1778827772/state/server.pid b/.superpowers/brainstorm/1616-1778827772/state/server.pid new file mode 100644 index 0000000..eff9e16 --- /dev/null +++ b/.superpowers/brainstorm/1616-1778827772/state/server.pid @@ -0,0 +1 @@ +1616 diff --git a/client/src/entities/product/api/product-api.ts b/client/src/entities/product/api/product-api.ts index fdd0c5b..f317aa8 100644 --- a/client/src/entities/product/api/product-api.ts +++ b/client/src/entities/product/api/product-api.ts @@ -14,7 +14,6 @@ export async function fetchPublicProducts(params?: { categorySlug?: string q?: string sort?: 'price_asc' | 'price_desc' | '' - availability?: 'all' | 'in_stock' | 'made_to_order' page?: number pageSize?: number priceMinCents?: number @@ -25,7 +24,6 @@ export async function fetchPublicProducts(params?: { categorySlug: params?.categorySlug || undefined, q: params?.q || undefined, sort: params?.sort || undefined, - availability: params?.availability || undefined, page: params?.page || undefined, pageSize: params?.pageSize || undefined, priceMin: params?.priceMinCents ?? undefined, @@ -55,16 +53,13 @@ export async function createProduct(body: { slug?: string shortDescription?: string | null description?: string | null - quantity?: number | null + quantity: number materials?: string[] priceCents: number imageUrl?: string | null imageUrls?: string[] published: boolean - inStock?: boolean - leadTimeDays?: number | null - /** Пустая строка / отсутствует — категория «Не указано» на сервере */ - categoryId?: string + categoryId: string }): Promise { const { data } = await apiClient.post('admin/products', body) return data @@ -77,15 +72,13 @@ export async function updateProduct( slug: string shortDescription: string | null description: string | null - quantity: number | null + quantity: number materials: string[] priceCents: number imageUrl: string | null imageUrls: string[] published: boolean - inStock: boolean - leadTimeDays: number | null - categoryId?: string + categoryId: string }>, ): Promise { const { data } = await apiClient.patch(`admin/products/${id}`, body) diff --git a/client/src/entities/product/model/types.ts b/client/src/entities/product/model/types.ts index 4042dac..a0cf2d4 100644 --- a/client/src/entities/product/model/types.ts +++ b/client/src/entities/product/model/types.ts @@ -23,8 +23,6 @@ export type Product = { imageUrl: string | null imageUrls?: string[] // legacy-friendly (used only in admin payloads) published: boolean - inStock: boolean - leadTimeDays: number | null categoryId: string createdAt: string updatedAt: string diff --git a/client/src/entities/product/ui/ProductCard.tsx b/client/src/entities/product/ui/ProductCard.tsx index 8496c2f..e2d4b90 100644 --- a/client/src/entities/product/ui/ProductCard.tsx +++ b/client/src/entities/product/ui/ProductCard.tsx @@ -44,12 +44,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) { navigate(`/products/${product.id}`) }, [navigate, product.id]) - const stockLabel = - product.inStock && product.quantity === 0 - ? { label: 'Нет в наличии', color: 'default' as const } - : !product.inStock - ? { label: `Под заказ · ${product.leadTimeDays ?? '—'} дн.`, color: 'warning' as const } - : null + const stockLabel = product.quantity > 0 ? null : { label: 'Нет в наличии', color: 'default' as const } return ( )} diff --git a/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx b/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx index 359999c..fea5d09 100644 --- a/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx +++ b/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx @@ -63,7 +63,7 @@ export function ToggleCartIcon(props: { - {user ? (inCart ? : ) : } + {user ? inCart ? : : } diff --git a/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx b/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx index 22040bf..4b53317 100644 --- a/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx +++ b/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx @@ -148,7 +148,7 @@ export function AdminCategoriesPage() { {c.sort} openCategoryEdit(c)} + onEdit={c.slug === UNSPECIFIED_CATEGORY_SLUG ? undefined : () => openCategoryEdit(c)} onDelete={c.slug === UNSPECIFIED_CATEGORY_SLUG ? undefined : () => setCategoryDeleteTarget(c)} /> diff --git a/client/src/pages/admin-products/ui/AdminProductsPage.tsx b/client/src/pages/admin-products/ui/AdminProductsPage.tsx index 2064825..b4310ba 100644 --- a/client/src/pages/admin-products/ui/AdminProductsPage.tsx +++ b/client/src/pages/admin-products/ui/AdminProductsPage.tsx @@ -51,8 +51,6 @@ type FormState = { priceRub: string imageUrls: string[] published: boolean - inStock: boolean - leadTimeDays: string categoryId: string } @@ -61,13 +59,11 @@ const emptyForm = (): FormState => ({ slug: '', shortDescription: '', description: '', - quantity: '', + quantity: '0', materials: '', priceRub: '', imageUrls: [], published: true, - inStock: true, - leadTimeDays: '', categoryId: '', }) @@ -83,7 +79,6 @@ export function AdminProductsPage() { }) const titleValue = productForm.watch('title') - const inStockValue = productForm.watch('inStock') const categoriesQuery = useQuery({ queryKey: ['categories'], @@ -118,13 +113,11 @@ export function AdminProductsPage() { slug: p.slug, shortDescription: p.shortDescription ?? '', description: p.description ?? '', - quantity: p.quantity === null || p.quantity === undefined ? '' : String(p.quantity), + quantity: String(p.quantity), materials: (p.materials ?? []).join(', '), priceRub: String(p.priceCents / 100), imageUrls: urls, published: p.published, - inStock: p.inStock, - leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '', categoryId: p.categoryId, }) } @@ -134,14 +127,11 @@ export function AdminProductsPage() { const form = productForm.getValues() const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') - const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null - if (!form.inStock) { - if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) { - throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0') - } - } - const qty = form.quantity.trim() ? Number(form.quantity) : null - if (qty !== null && (!Number.isFinite(qty) || qty < 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()) @@ -151,13 +141,11 @@ export function AdminProductsPage() { slug: form.slug.trim() || undefined, shortDescription: form.shortDescription.trim() || null, description: form.description.trim() || null, - quantity: qty === null ? null : Math.floor(qty), + quantity: Math.floor(qtyNum), materials, priceCents, imageUrls: form.imageUrls, published: form.published, - inStock: form.inStock, - leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!), categoryId: form.categoryId, }) }, @@ -172,14 +160,11 @@ export function AdminProductsPage() { const form = productForm.getValues() const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') - const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null - if (!form.inStock) { - if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) { - throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0') - } - } - const qty = form.quantity.trim() ? Number(form.quantity) : null - if (qty !== null && (!Number.isFinite(qty) || qty < 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()) @@ -189,13 +174,11 @@ export function AdminProductsPage() { slug: form.slug.trim(), shortDescription: form.shortDescription.trim() || null, description: form.description.trim() || null, - quantity: qty === null ? null : Math.floor(qty), + quantity: Math.floor(qtyNum), materials, priceCents, imageUrls: form.imageUrls, published: form.published, - inStock: form.inStock, - leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!), categoryId: form.categoryId, }) }, @@ -364,13 +347,7 @@ export function AdminProductsPage() { control={productForm.control} name="quantity" render={({ field }) => ( - + )} /> ( - + Категория + {!field.value && Выберите категорию} )} /> @@ -498,23 +473,6 @@ export function AdminProductsPage() { /> )} /> - ( - field.onChange(v)} />} - label={field.value ? 'В наличии' : 'Под заказ'} - /> - )} - /> - {!inStockValue && ( - } - /> - )} @@ -522,7 +480,13 @@ export function AdminProductsPage() { diff --git a/client/src/pages/admin/ui/AdminPage.tsx b/client/src/pages/admin/ui/AdminPage.tsx index 9253d1e..2a4d34c 100644 --- a/client/src/pages/admin/ui/AdminPage.tsx +++ b/client/src/pages/admin/ui/AdminPage.tsx @@ -59,8 +59,6 @@ type FormState = { priceRub: string imageUrls: string[] published: boolean - inStock: boolean - leadTimeDays: string categoryId: string } @@ -69,13 +67,11 @@ const emptyForm = (): FormState => ({ slug: '', shortDescription: '', description: '', - quantity: '', + quantity: '0', materials: '', priceRub: '', imageUrls: [], published: true, - inStock: true, - leadTimeDays: '', categoryId: '', }) @@ -101,7 +97,6 @@ export function AdminPage() { }) const titleValue = productForm.watch('title') - const inStockValue = productForm.watch('inStock') const categoriesQuery = useQuery({ queryKey: ['categories'], @@ -147,13 +142,11 @@ export function AdminPage() { slug: p.slug, shortDescription: p.shortDescription ?? '', description: p.description ?? '', - quantity: p.quantity === null || p.quantity === undefined ? '' : String(p.quantity), + quantity: String(p.quantity), materials: (p.materials ?? []).join(', '), priceRub: String(p.priceCents / 100), imageUrls: urls, published: p.published, - inStock: p.inStock, - leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '', categoryId: p.categoryId, }) } @@ -163,14 +156,11 @@ export function AdminPage() { const form = productForm.getValues() const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') - const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null - if (!form.inStock) { - if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) { - throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0') - } - } - const qty = form.quantity.trim() ? Number(form.quantity) : null - if (qty !== null && (!Number.isFinite(qty) || qty < 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()) @@ -180,13 +170,11 @@ export function AdminPage() { slug: form.slug.trim() || undefined, shortDescription: form.shortDescription.trim() || null, description: form.description.trim() || null, - quantity: qty === null ? null : Math.floor(qty), + quantity: Math.floor(qtyNum), materials, priceCents, imageUrls: form.imageUrls, published: form.published, - inStock: form.inStock, - leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!), categoryId: form.categoryId, }) }, @@ -201,14 +189,11 @@ export function AdminPage() { const form = productForm.getValues() const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') - const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null - if (!form.inStock) { - if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) { - throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0') - } - } - const qty = form.quantity.trim() ? Number(form.quantity) : null - if (qty !== null && (!Number.isFinite(qty) || qty < 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()) @@ -218,13 +203,11 @@ export function AdminPage() { slug: form.slug.trim(), shortDescription: form.shortDescription.trim() || null, description: form.description.trim() || null, - quantity: qty === null ? null : Math.floor(qty), + quantity: Math.floor(qtyNum), materials, priceCents, imageUrls: form.imageUrls, published: form.published, - inStock: form.inStock, - leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!), categoryId: form.categoryId, }) }, @@ -517,13 +500,7 @@ export function AdminPage() { control={productForm.control} name="quantity" render={({ field }) => ( - + )} /> ( - + Категория + {!field.value && Выберите категорию} )} /> @@ -651,23 +626,6 @@ export function AdminPage() { /> )} /> - ( - field.onChange(v)} />} - label={field.value ? 'В наличии' : 'Под заказ'} - /> - )} - /> - {!inStockValue && ( - } - /> - )} @@ -675,7 +633,13 @@ export function AdminPage() { diff --git a/client/src/pages/cart/ui/CartPage.tsx b/client/src/pages/cart/ui/CartPage.tsx index a952105..b849e78 100644 --- a/client/src/pages/cart/ui/CartPage.tsx +++ b/client/src/pages/cart/ui/CartPage.tsx @@ -63,7 +63,7 @@ export function CartPage() { {items.map((x) => (() => { - const available = x.product.inStock ? x.product.quantity : 1 + const available = x.product.quantity const canInc = x.qty < available const over = x.qty > available return ( @@ -83,9 +83,9 @@ export function CartPage() { {formatPriceRub(x.product.priceCents)} · {x.qty} шт. · Доступно: {available} - {!x.product.inStock && ( + {x.product.quantity === 0 && ( - Под заказ — доставка после изготовления + Нет в наличии )} {over && ( diff --git a/client/src/pages/checkout/ui/CheckoutPage.tsx b/client/src/pages/checkout/ui/CheckoutPage.tsx index 46a663f..ce3b7a6 100644 --- a/client/src/pages/checkout/ui/CheckoutPage.tsx +++ b/client/src/pages/checkout/ui/CheckoutPage.tsx @@ -80,8 +80,7 @@ export function CheckoutPage() { const deliveryFeeCents = deliveryType === 'delivery' && items.length > 0 ? 50000 : 0 const total = itemsSubtotalCents + deliveryFeeCents const addresses = addressesQuery.data?.items ?? [] - const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1)) - const hasMadeToOrder = items.some((x) => !x.product.inStock) + const hasOverLimit = items.some((x) => x.qty > x.product.quantity) return ( @@ -105,7 +104,7 @@ export function CheckoutPage() { Позиции {items.map((x) => { - const available = x.product.inStock ? x.product.quantity : 1 + const available = x.product.quantity const over = x.qty > available return ( @@ -127,12 +126,6 @@ export function CheckoutPage() { )} - {hasMadeToOrder && ( - - В заказе есть товары «под заказ». Доставка будет после изготовления (срок указан в карточке товара). - - )} - Способ получения