From 886661678263f9a1dd4a9bb13f0806db6764e099 Mon Sep 17 00:00:00 2001 From: zabudko-ru Date: Wed, 21 Oct 2026 03:32:17 +0400 Subject: [PATCH] Products: create services from the admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "New service": from scratch (category, who does it — cleaner, driver, career consultant or docs agent —, fixed price and duration, online, name and description in 5 languages) or as a copy of an existing service (prices, questions, checklist, booking window and question texts copied). Created switched off; delete only without orders; reorder within a category. - "Questions in the order form" builder for fixed-price services: choice, number, text, yes/no, required flag; labels stored as text overrides. - The admin itself now loads text overrides, so new service names show right away; the mini app hides missing optional texts (description, option hints) instead of showing keys. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/api/src/routers/backoffice/products.ts | 151 ++++++++- apps/backoffice/src/admin5.css | 7 + apps/backoffice/src/i18n.tsx | 134 +++++++- apps/backoffice/src/main.tsx | 5 +- apps/backoffice/src/pages/Products.tsx | 299 +++++++++++++++++- apps/miniapp-client/src/screens/OrderForm.tsx | 6 +- .../src/screens/ServiceRequest.tsx | 4 +- packages/web/src/i18n.tsx | 6 + 8 files changed, 599 insertions(+), 13 deletions(-) diff --git a/apps/api/src/routers/backoffice/products.ts b/apps/api/src/routers/backoffice/products.ts index 4283f09..7b3550d 100644 --- a/apps/api/src/routers/backoffice/products.ts +++ b/apps/api/src/routers/backoffice/products.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { CATEGORY_CODES, DAY, fromPrice, paramsSchemaSchema, pricingSchema, slotConfigSchema } from '@barev/core'; -import { defaultText, LOCALES, type Locale } from '@barev/i18n'; +import { CATEGORY_CODES, DAY, fromPrice, paramsSchemaSchema, pricingSchema, ROLE_FLOWS, slotConfigSchema } from '@barev/core'; +import { createT, defaultText, LOCALES, type Locale } from '@barev/i18n'; import { isEditableTextKey, listRelocationItems, loadTextOverrides, moveRelocationItem, normalizePromo, saveRelocationItem } from '@barev/services'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; @@ -11,6 +11,18 @@ const view = rolesProcedure('SUPPORT'); const edit = superProcedure; const localized = z.object({ ru: z.string().max(500), en: z.string().max(500), zh: z.string().max(500), hy: z.string().max(500), es: z.string().max(500) }); +/** + * С какими исполнителями можно завести новую услугу с нуля: у них простой сценарий заказа. + * Ремонт (смета) и уроки (пакет занятий) — только копией существующей услуги. + */ +const NEW_SERVICE_ROLES = ['CLEANER', 'DRIVER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const; +const DEFAULT_SLOTS = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 }; + +/** Ключи подписей вопросов услуги: params.<код>.<поле>.label и варианты */ +function paramKeys(schema: { fields: { key: string; type: string; options?: string[] }[] }, code: string) { + return schema.fields.flatMap((f) => [`params.${code}.${f.key}.label`, ...(f.options ?? []).flatMap((o) => [`params.${code}.${f.key}.${o}`, `params.${code}.${f.key}.${o}_hint`])]); +} + /** Текст по ключу на 5 языках: стандартный (из переводов) и изменённый в админке */ async function textsFor(db: import('@barev/db').Db, keys: string[]) { const rows = await db.textOverride.findMany({ where: { key: { in: keys } } }); @@ -105,6 +117,141 @@ export const boProductsRouter = router({ await loadTextOverrides(ctx.db); }), + // ───── новые услуги ───── + + /** + * Новая услуга: с нуля (фикс-цена, свои вопросы клиенту) или копия существующей (с её ценами, вопросами и чек-листом). + * Код генерируется; название и описание — тексты из админки. Создаётся выключенной: включите, когда всё настроено. + */ + createService: edit + .input( + z.object({ + categoryId: z.string(), + name: localized.extend({ ru: z.string().trim().min(1).max(120) }), + description: localized, + copyFromId: z.string().optional(), + providerRole: z.enum(NEW_SERVICE_ROLES).optional(), + isOnline: z.boolean().default(false), + price: z.number().int().min(0).default(0), + minutes: z.number().int().min(15).max(24 * 60).default(60), + }), + ) + .mutation(async ({ ctx, input }) => { + const code = `s${randomBytes(4).toString('hex')}`; + const src = input.copyFromId ? await ctx.db.service.findUnique({ where: { id: input.copyFromId }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } }) : null; + if (input.copyFromId && !src) throw new TRPCError({ code: 'NOT_FOUND' }); + if (!src && !input.providerRole) throw new TRPCError({ code: 'BAD_REQUEST', message: 'ROLE_REQUIRED' }); + const role = src?.providerRole ?? input.providerRole!; + const last = await ctx.db.service.findFirst({ where: { categoryId: input.categoryId }, orderBy: { sortOrder: 'desc' } }); + const created = await ctx.db.service.create({ + data: { + code, + categoryId: input.categoryId, + flow: src?.flow ?? ROLE_FLOWS[role][0]!, + providerRole: role, + isOnline: src?.isOnline ?? input.isOnline, + pricing: src ? (src.pricing as object) : { kind: 'fixed', price: input.price, minutes: input.minutes }, + paramsSchema: src ? (src.paramsSchema as object) : { fields: [] }, + slotConfig: src ? (src.slotConfig as object) : DEFAULT_SLOTS, + lateCancelFeePct: src?.lateCancelFeePct ?? 30, + active: false, + sortOrder: (last?.sortOrder ?? 0) + 1, + checklist: src ? { create: src.checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })) } : undefined, + }, + }); + // тексты: название и описание; у копии — ещё подписи вопросов (params.<код>.*) на всех языках + const rows: { key: string; locale: Locale; value: string }[] = []; + for (const l of LOCALES) { + if (input.name[l]?.trim()) rows.push({ key: `services.${code}.name`, locale: l, value: input.name[l].trim() }); + if (input.description[l]?.trim()) rows.push({ key: `services.${code}.description`, locale: l, value: input.description[l].trim() }); + } + if (src) { + for (const l of LOCALES) { + const t = createT(l); + for (const key of paramKeys(paramsSchemaSchema.parse(src.paramsSchema), src.code)) { + const v = t(key); + if (v !== key) rows.push({ key: key.replace(`params.${src.code}.`, `params.${code}.`), locale: l, value: v }); + } + } + } + for (const r of rows) await ctx.db.textOverride.create({ data: { ...r, updatedById: ctx.admin.id } }); + await loadTextOverrides(ctx.db); + return { id: created.id }; + }), + + /** + * Вопросы клиенту при заказе (для услуг с фикс-ценой): выбор из вариантов, число, текст, да/нет. + * Подписи — по-русски (в других языках клиент увидит русский, пока нет перевода). + */ + saveParams: edit + .input( + z.object({ + serviceId: z.string(), + fields: z + .array( + z.object({ + key: z.string().max(40).optional(), + type: z.enum(['select', 'int', 'text', 'bool']), + label: z.string().trim().min(1).max(120), + required: z.boolean(), + options: z.array(z.object({ key: z.string().max(40).optional(), label: z.string().trim().min(1).max(120) })).max(20).default([]), + min: z.number().int().min(0).max(1000).default(1), + max: z.number().int().min(1).max(1000).default(10), + }), + ) + .max(12), + }), + ) + .mutation(async ({ ctx, input }) => { + const s = await ctx.db.service.findUnique({ where: { id: input.serviceId } }); + if (!s) throw new TRPCError({ code: 'NOT_FOUND' }); + if (pricingSchema.parse(s.pricing).kind !== 'fixed') throw new TRPCError({ code: 'BAD_REQUEST', message: 'PARAMS_LOCKED' }); + const short = () => randomBytes(2).toString('hex'); + const fields = input.fields.map((f) => ({ ...f, key: f.key || `q${short()}`, options: f.options.map((o) => ({ ...o, key: o.key || `o${short()}` })) })); + for (const f of fields) if (f.type === 'select' && f.options.length < 2) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OPTIONS_REQUIRED' }); + const schema = { + fields: fields.map((f) => + f.type === 'select' + ? { key: f.key, type: 'select' as const, options: f.options.map((o) => o.key), required: f.required } + : f.type === 'int' + ? { key: f.key, type: 'int' as const, min: Math.min(f.min, f.max), max: Math.max(f.min, f.max), default: Math.min(f.min, f.max), required: f.required } + : f.type === 'text' + ? { key: f.key, type: 'text' as const, maxLength: 1000, required: f.required } + : { key: f.key, type: 'bool' as const, default: false, required: false }, + ), + }; + paramsSchemaSchema.parse(schema); + await ctx.db.service.update({ where: { id: s.id }, data: { paramsSchema: schema } }); + // подписи: старые подписи вопросов этой услуги убираем, новые пишем + await ctx.db.textOverride.deleteMany({ where: { key: { startsWith: `params.${s.code}.` }, locale: 'ru' } }); + for (const f of fields) { + await ctx.db.textOverride.create({ data: { key: `params.${s.code}.${f.key}.label`, locale: 'ru', value: f.label, updatedById: ctx.admin.id } }); + for (const o of f.options) await ctx.db.textOverride.create({ data: { key: `params.${s.code}.${f.key}.${o.key}`, locale: 'ru', value: o.label, updatedById: ctx.admin.id } }); + } + await loadTextOverrides(ctx.db); + }), + + /** Удалить можно только услугу без заказов; иначе — выключить */ + deleteService: edit.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { + const s = await ctx.db.service.findUnique({ where: { id: input.id }, include: { _count: { select: { orders: true, webRequests: true } } } }); + if (!s) throw new TRPCError({ code: 'NOT_FOUND' }); + if (s._count.orders || s._count.webRequests) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SERVICE_HAS_ORDERS' }); + await ctx.db.service.delete({ where: { id: s.id } }); + await ctx.db.textOverride.deleteMany({ where: { OR: [{ key: { startsWith: `services.${s.code}.` } }, { key: { startsWith: `params.${s.code}.` } }] } }); + await loadTextOverrides(ctx.db); + }), + + /** Порядок услуг в направлении — как в каталоге у клиента */ + moveService: edit.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(async ({ ctx, input }) => { + const s = await ctx.db.service.findUniqueOrThrow({ where: { id: input.id } }); + const list = await ctx.db.service.findMany({ where: { categoryId: s.categoryId }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }); + const i = list.findIndex((x) => x.id === s.id); + const j = i + input.dir; + if (j < 0 || j >= list.length) return; + [list[i], list[j]] = [list[j]!, list[i]!]; + await ctx.db.$transaction(list.map((x, k) => ctx.db.service.update({ where: { id: x.id }, data: { sortOrder: k } }))); + }), + // ───── промокоды ───── promos: view.query(async ({ ctx }) => { diff --git a/apps/backoffice/src/admin5.css b/apps/backoffice/src/admin5.css index 6c7494b..316cbfa 100644 --- a/apps/backoffice/src/admin5.css +++ b/apps/backoffice/src/admin5.css @@ -116,3 +116,10 @@ grid-template-columns: minmax(0, 1fr); } } + +/* вопрос клиенту в редакторе услуги */ +.bo-qfield { + padding: 12px; + border-radius: 16px; + background: var(--bo-soft); +} diff --git a/apps/backoffice/src/i18n.tsx b/apps/backoffice/src/i18n.tsx index ef78015..5e1ba40 100644 --- a/apps/backoffice/src/i18n.tsx +++ b/apps/backoffice/src/i18n.tsx @@ -1,4 +1,4 @@ -import { createT as createSharedT, formatAmd, formatDateTime, type Locale as SharedLocale } from '@barev/i18n'; +import { createT as createSharedT, formatAmd, formatDateTime, onTextOverrides, textOverridesVersion, type Locale as SharedLocale } from '@barev/i18n'; import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; /** Языки новой админки */ @@ -386,6 +386,47 @@ const ru = { 'chat.notLinked': 'Рабочий бот не открыт', 'chat.details': 'Подробнее', 'chat.more': 'Ещё действия', + 'pr.newService': 'Новая услуга', + 'pr.newServiceHint': 'С нуля — с фиксированной ценой и вашими вопросами клиенту, или копией готовой услуги.', + 'pr.fromScratch': 'С нуля', + 'pr.copyOf': 'Копия готовой', + 'pr.category': 'Направление', + 'pr.copyFrom': 'Скопировать услугу', + 'pr.copyHint': 'Скопируются цены, вопросы клиенту, чек-лист и окно записи — потом поменяете что нужно. Так можно сделать, например, «Генеральную уборку офиса» из клининга.', + 'pr.who': 'Кто выполняет', + 'pr.whoHint': 'Определяет, кому система предлагает заказ и какие шаги видит исполнитель. Ремонт и уроки — только копией готовой услуги.', + 'pr.flowHint.CLEANER': 'выехал → работа → готово, с чек-листом', + 'pr.flowHint.DRIVER': 'водитель на месте → в пути → завершено', + 'pr.flowHint.CAREER_CONSULTANT': 'консультант → материалы → встреча → итог', + 'pr.flowHint.DOCS_AGENT': 'проверка документов → подача → готово', + 'pr.online': 'Онлайн — без адреса и выезда', + 'pr.namePh': 'Например: Мойка окон', + 'pr.create': 'Создать услугу', + 'pr.createHint': 'Услуга появится выключенной: настройте цену, вопросы и чек-лист, затем включите её в «Запись и отмена» — и она сразу появится в приложении.', + 'pr.created': 'Услуга создана — осталось настроить и включить', + 'pr.questions': 'Вопросы клиенту при заказе', + 'pr.questionsHint': 'Что клиент выбирает или пишет в форме заказа. Ответы видят исполнитель и команда.', + 'pr.qLabel': 'Вопрос, например «Сколько окон?»', + 'pr.qType.select': 'выбор варианта', + 'pr.qType.int': 'число', + 'pr.qType.text': 'текст', + 'pr.qType.bool': 'да / нет', + 'pr.qOption': 'Вариант {n}', + 'pr.qAddOption': 'вариант', + 'pr.qFrom': 'от', + 'pr.qTo': 'до', + 'pr.qRequired': 'обязательно', + 'pr.qAdd': 'Вопрос', + 'pr.qNone': 'Вопросов нет — клиент выберет только время и адрес.', + 'pr.delete': 'Удалить услугу', + 'pr.deleteConfirm': 'Удалить услугу? Можно, только если по ней ещё не было заказов — иначе выключите её.', + 'err.SERVICE_HAS_ORDERS': 'По услуге уже были заказы — удалить нельзя, выключите её в «Запись и отмена».', + 'err.OPTIONS_REQUIRED': 'У вопроса с выбором нужно минимум 2 варианта.', + 'err.PARAMS_LOCKED': 'У этой услуги цена зависит от ответов — вопросы меняются программистом.', + 'audit.bo.products.createService': 'создал(а) услугу', + 'audit.bo.products.saveParams': 'изменил(а) вопросы услуги', + 'audit.bo.products.deleteService': 'удалил(а) услугу', + 'audit.bo.products.moveService': 'изменил(а) порядок услуг', 'audit.bo.crm.addTask': 'поставил(а) задачу', 'audit.bo.crm.setTaskDone': 'отметил(а) задачу', 'audit.bo.crm.removeTask': 'удалил(а) задачу', @@ -1441,6 +1482,47 @@ const en: Dict = { 'chat.notLinked': 'Work bot not opened', 'chat.details': 'Details', 'chat.more': 'More actions', + 'pr.newService': 'New service', + 'pr.newServiceHint': 'From scratch with a fixed price and your own questions, or as a copy of an existing service.', + 'pr.fromScratch': 'From scratch', + 'pr.copyOf': 'Copy of existing', + 'pr.category': 'Category', + 'pr.copyFrom': 'Copy service', + 'pr.copyHint': 'Prices, questions, checklist and booking window are copied — change what you need. E.g. an “Office deep clean” from cleaning.', + 'pr.who': 'Who does it', + 'pr.whoHint': 'Decides who gets the order and which steps the provider sees. Repairs and lessons — only as a copy.', + 'pr.flowHint.CLEANER': 'on the way → work → done, with a checklist', + 'pr.flowHint.DRIVER': 'driver arrived → riding → done', + 'pr.flowHint.CAREER_CONSULTANT': 'consultant → materials → meeting → result', + 'pr.flowHint.DOCS_AGENT': 'document check → submission → done', + 'pr.online': 'Online — no address or visit', + 'pr.namePh': 'E.g. Window cleaning', + 'pr.create': 'Create service', + 'pr.createHint': 'The service starts switched off: set price, questions and checklist, then switch it on — it appears in the app right away.', + 'pr.created': 'Service created — set it up and switch it on', + 'pr.questions': 'Questions in the order form', + 'pr.questionsHint': 'What the client picks or types when ordering. The provider and team see the answers.', + 'pr.qLabel': 'Question, e.g. “How many windows?”', + 'pr.qType.select': 'choice', + 'pr.qType.int': 'number', + 'pr.qType.text': 'text', + 'pr.qType.bool': 'yes / no', + 'pr.qOption': 'Option {n}', + 'pr.qAddOption': 'option', + 'pr.qFrom': 'from', + 'pr.qTo': 'to', + 'pr.qRequired': 'required', + 'pr.qAdd': 'Question', + 'pr.qNone': 'No questions — the client picks only time and address.', + 'pr.delete': 'Delete service', + 'pr.deleteConfirm': 'Delete the service? Only possible without orders — otherwise switch it off.', + 'err.SERVICE_HAS_ORDERS': 'The service has orders — switch it off instead.', + 'err.OPTIONS_REQUIRED': 'A choice question needs at least 2 options.', + 'err.PARAMS_LOCKED': 'This service’s price depends on the answers — questions are changed by a developer.', + 'audit.bo.products.createService': 'created a service', + 'audit.bo.products.saveParams': 'edited service questions', + 'audit.bo.products.deleteService': 'deleted a service', + 'audit.bo.products.moveService': 'reordered services', 'audit.bo.crm.addTask': 'added a task', 'audit.bo.crm.setTaskDone': 'updated a task', 'audit.bo.crm.removeTask': 'deleted a task', @@ -2493,6 +2575,47 @@ const hy: Dict = { 'chat.notLinked': 'Աշխատանքային բոտը բացված չէ', 'chat.details': 'Մանրամասներ', 'chat.more': 'Այլ գործողություններ', + 'pr.newService': 'Նոր ծառայություն', + 'pr.newServiceHint': 'Զրոյից՝ ֆիքսված գնով, կամ պատրաստի ծառայության պատճենով։', + 'pr.fromScratch': 'Զրոյից', + 'pr.copyOf': 'Պատրաստիի պատճեն', + 'pr.category': 'Ուղղություն', + 'pr.copyFrom': 'Պատճենել ծառայությունը', + 'pr.copyHint': 'Կպատճենվեն գները, հարցերը, ստուգացանկը։', + 'pr.who': 'Ով է կատարում', + 'pr.whoHint': 'Որոշում է, ում է առաջարկվում պատվերը։', + 'pr.flowHint.CLEANER': 'ճանապարհին → աշխատանք → պատրաստ', + 'pr.flowHint.DRIVER': 'վարորդը տեղում → ճանապարհին → ավարտ', + 'pr.flowHint.CAREER_CONSULTANT': 'խորհրդատու → հանդիպում → արդյունք', + 'pr.flowHint.DOCS_AGENT': 'ստուգում → ներկայացում → պատրաստ', + 'pr.online': 'Առցանց՝ առանց հասցեի', + 'pr.namePh': 'Օրինակ՝ Պատուհանների լվացում', + 'pr.create': 'Ստեղծել ծառայությունը', + 'pr.createHint': 'Ծառայությունը կստեղծվի անջատված՝ կարգավորեք և միացրեք։', + 'pr.created': 'Ծառայությունը ստեղծված է', + 'pr.questions': 'Հարցեր պատվերի ժամանակ', + 'pr.questionsHint': 'Ինչ է ընտրում հաճախորդը պատվերի ձևում։', + 'pr.qLabel': 'Հարց', + 'pr.qType.select': 'ընտրություն', + 'pr.qType.int': 'թիվ', + 'pr.qType.text': 'տեքստ', + 'pr.qType.bool': 'այո / ոչ', + 'pr.qOption': 'Տարբերակ {n}', + 'pr.qAddOption': 'տարբերակ', + 'pr.qFrom': 'սկսած', + 'pr.qTo': 'մինչև', + 'pr.qRequired': 'պարտադիր', + 'pr.qAdd': 'Հարց', + 'pr.qNone': 'Հարցեր չկան։', + 'pr.delete': 'Ջնջել ծառայությունը', + 'pr.deleteConfirm': 'Ջնջե՞լ ծառայությունը։', + 'err.SERVICE_HAS_ORDERS': 'Ծառայությունն ունի պատվերներ՝ անջատեք այն։', + 'err.OPTIONS_REQUIRED': 'Անհրաժեշտ է նվազագույնը 2 տարբերակ։', + 'err.PARAMS_LOCKED': 'Այս ծառայության հարցերը փոխում է ծրագրավորողը։', + 'audit.bo.products.createService': 'ստեղծեց ծառայություն', + 'audit.bo.products.saveParams': 'փոխեց ծառայության հարցերը', + 'audit.bo.products.deleteService': 'ջնջեց ծառայությունը', + 'audit.bo.products.moveService': 'փոխեց հերթականությունը', 'audit.bo.crm.addTask': 'ավելացրեց առաջադրանք', 'audit.bo.crm.setTaskDone': 'նշեց առաջադրանքը', 'audit.bo.crm.removeTask': 'ջնջեց առաջադրանքը', @@ -3214,6 +3337,12 @@ export function BoI18nProvider({ children }: { children: ReactNode }) { useEffect(() => { document.documentElement.lang = locale; }, [locale]); + // тексты, изменённые в «Продуктах» (названия услуг и т.п.), — перерисовать, когда подгрузились + const [textsVersion, setTextsVersion] = useState(textOverridesVersion()); + useEffect(() => { + const off = onTextOverrides(() => setTextsVersion(textOverridesVersion())); + return () => void off(); + }, []); const value = useMemo(() => { const sharedT = createSharedT(locale as SharedLocale); const intl = locale === 'en' ? 'en-GB' : locale === 'hy' ? 'hy-AM' : 'ru-RU'; @@ -3237,7 +3366,8 @@ export function BoI18nProvider({ children }: { children: ReactNode }) { dateTime: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }), time: (d) => formatDateTime(locale as SharedLocale, d, { hour: '2-digit', minute: '2-digit' }), }; - }, [locale]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [locale, textsVersion]); return {children}; } diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx index 6660fe5..39b485d 100644 --- a/apps/backoffice/src/main.tsx +++ b/apps/backoffice/src/main.tsx @@ -4,12 +4,15 @@ import './chats.css'; import './orders.css'; import './crm.css'; import './admin5.css'; -import { ApiProvider, ToastProvider } from '@barev/web'; +import { ApiProvider, loadTextOverrides, ToastProvider } from '@barev/web'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App.tsx'; import { BoI18nProvider } from './i18n.tsx'; +// названия услуг и другие тексты, изменённые в «Продуктах» +void loadTextOverrides(); + createRoot(document.getElementById('root')!).render( diff --git a/apps/backoffice/src/pages/Products.tsx b/apps/backoffice/src/pages/Products.tsx index 5374572..50edbca 100644 --- a/apps/backoffice/src/pages/Products.tsx +++ b/apps/backoffice/src/pages/Products.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { navigate, useLocation, useToast, useTRPC } from '@barev/web'; +import { loadTextOverrides, navigate, useLocation, useToast, useTRPC } from '@barev/web'; import { useEffect, useMemo, useState } from 'react'; import type { Me } from '../App.tsx'; import { errText, useT } from '../i18n.tsx'; @@ -36,7 +36,7 @@ export function Products({ me }: { me: Me }) { ))} {!canEdit &&

{t('pr.readOnly')}

} - {tab === 'services' && } + {tab === 'services' && } {tab === 'plans' && } {tab === 'promos' && } {tab === 'relocation' && } @@ -46,13 +46,26 @@ export function Products({ me }: { me: Me }) { // ───── услуги ───── -function Services() { +function Services({ canEdit }: { canEdit: boolean }) { const { t, shared, amd } = useT(); const trpc = useTRPC(); + const qc = useQueryClient(); const cat = useQuery(trpc.bo.products.catalog.queryOptions()); + const [creating, setCreating] = useState(false); + const move = useMutation(trpc.bo.products.moveService.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() }) })); if (!cat.data) return ; return (
+ {canEdit && ( +
+ + {t('pr.newServiceHint')} +
+ )} + {creating && setCreating(false)} />} {cat.data.map((c) => (
@@ -62,6 +75,16 @@ function Services() {