Products: create services from the admin
- "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) <noreply@anthropic.com>
This commit is contained in:
1 parent
0edc6432b7
commit
8866616782
8 files changed
+599
-13
No files matched your search
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user