Backoffice stage 5: products, broadcasts, activity log, settings
- Products: services with full setup — content (name, description, extras names in 5 languages via text overrides), price tiers and extras, booking window, late-cancel fee, on/off, checklist; category discounts by membership level; membership plan prices; promo codes; relocation steps and documents. - Text overrides: TextOverride table on top of the i18n files; API, bots and the worker reload them every minute, mini apps fetch public.texts at start. - Promo codes applied to orders (admin new-order form and client API): not stacked with membership discount — the larger wins; once per client; validity, limit and categories. - Broadcasts to the client bot by any client filter, per-language texts with Russian fallback, test to yourself, ~25 msg/s, per-recipient status, resume after worker restart, history with progress and stop. - Activity log (who/when/what, filters, details) and Settings page with all owner settings grouped, plus payment methods. - Tests for promo codes and text overrides. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ac786cc5cc
commit
0edc6432b7
34 files changed
+2704
-26
No files matched your search
@@ -23,6 +23,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1152.0",
|
||||
"@barev/core": "workspace:*",
|
||||
"@barev/db": "workspace:*",
|
||||
"@barev/i18n": "workspace:*",
|
||||
"@barev/services": "workspace:*",
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/** Этап 5: рассылки клиентам, журнал действий, настройки */
|
||||
import { DAY, SETTING_KEYS, settingsSchema } from '@barev/core';
|
||||
import type { Prisma } from '@barev/db';
|
||||
import { getSettings } from '@barev/services';
|
||||
import { TelegramSender } from '@barev/services/telegram';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../../env.ts';
|
||||
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
|
||||
import { clientFilters, filteredClients } from './clients.ts';
|
||||
|
||||
const LANGS = ['ru', 'en', 'zh', 'hy', 'es'] as const;
|
||||
const texts = z.object({ ru: z.string().trim().min(1).max(3500), en: z.string().max(3500), zh: z.string().max(3500), hy: z.string().max(3500), es: z.string().max(3500) });
|
||||
const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/** Рассылки делают поддержка и суперадмин */
|
||||
const bc = rolesProcedure('SUPPORT');
|
||||
|
||||
/** Получатели: клиенты по фильтрам, только давшие согласие (иначе бот им не писал) */
|
||||
async function recipients(db: import('@barev/db').Db, filters: z.infer<typeof clientFilters>, now: Date) {
|
||||
const { list } = await filteredClients(db, filters, now);
|
||||
return list.filter((c) => !!c.consentAt).map((c) => ({ id: c.id, locale: c.user.locale }));
|
||||
}
|
||||
|
||||
export const boBroadcastsRouter = router({
|
||||
/** Сколько клиентов получит и на каких языках */
|
||||
preview: bc.input(clientFilters).query(async ({ ctx, input }) => {
|
||||
const list = await recipients(ctx.db, input, ctx.svc.now());
|
||||
const byLocale = Object.fromEntries(LANGS.map((l) => [l, list.filter((c) => c.locale === l).length])) as Record<(typeof LANGS)[number], number>;
|
||||
return { total: list.length, byLocale };
|
||||
}),
|
||||
|
||||
/** Проверка: прислать себе в клиентский бот (нужно, чтобы вы хоть раз открыли его) */
|
||||
test: bc.input(z.object({ text: z.string().trim().min(1).max(3500), appButton: z.boolean() })).mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.admin.telegramId) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'TELEGRAM_NOT_LINKED' });
|
||||
const bot = new TelegramSender(env.CLIENT_BOT_TOKEN, 'client-bot');
|
||||
const app = env.CLIENT_MINIAPP_URL;
|
||||
const ok = await bot.send({
|
||||
chatId: ctx.admin.telegramId,
|
||||
text: escapeHtml(input.text),
|
||||
buttons: input.appButton && app.startsWith('https://') ? [[{ text: 'Barev Club', web_app: { url: app } }]] : [],
|
||||
});
|
||||
if (!ok) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'TEST_UNREACHABLE' });
|
||||
}),
|
||||
|
||||
/** Запустить рассылку: фиксируем получателей, воркер отправляет с ограничением скорости Telegram */
|
||||
send: bc.input(z.object({ filters: clientFilters, texts, appButton: z.boolean() })).mutation(async ({ ctx, input }) => {
|
||||
const list = await recipients(ctx.db, input.filters, ctx.svc.now());
|
||||
if (!list.length) throw new TRPCError({ code: 'BAD_REQUEST', message: 'NO_RECIPIENTS' });
|
||||
const b = await ctx.db.broadcast.create({
|
||||
data: {
|
||||
texts: input.texts,
|
||||
appButton: input.appButton,
|
||||
filters: input.filters as Prisma.InputJsonValue,
|
||||
total: list.length,
|
||||
createdById: ctx.admin.id,
|
||||
recipients: { createMany: { data: list.map((c) => ({ clientId: c.id })) } },
|
||||
},
|
||||
});
|
||||
await ctx.svc.notify.emit({ type: 'broadcast.start', broadcastId: b.id });
|
||||
return { id: b.id, total: list.length };
|
||||
}),
|
||||
|
||||
list: bc.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db.broadcast.findMany({ orderBy: { createdAt: 'desc' }, take: 50 });
|
||||
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: rows.map((r) => r.createdById).filter((x): x is string => !!x) } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
text: (r.texts as Record<string, string>).ru ?? '',
|
||||
status: r.status,
|
||||
total: r.total,
|
||||
sent: r.sent,
|
||||
blocked: r.blocked,
|
||||
failed: r.failed,
|
||||
by: r.createdById ? (admins.get(r.createdById) ?? null) : null,
|
||||
createdAt: r.createdAt,
|
||||
finishedAt: r.finishedAt,
|
||||
}));
|
||||
}),
|
||||
|
||||
cancel: bc.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.broadcast.updateMany({ where: { id: input.id, status: 'SENDING' }, data: { status: 'CANCELLED', finishedAt: ctx.svc.now() } });
|
||||
}),
|
||||
});
|
||||
|
||||
// ───── журнал действий ─────
|
||||
|
||||
export const boJournalRouter = router({
|
||||
list: superProcedure
|
||||
.input(z.object({ adminUserId: z.string().optional(), entity: z.string().max(40).optional(), days: z.number().int().min(1).max(365).default(30), cursor: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const since = new Date(ctx.svc.now().getTime() - input.days * DAY);
|
||||
const rows = await ctx.db.auditLog.findMany({
|
||||
where: { createdAt: { gte: since }, ...(input.adminUserId ? { adminUserId: input.adminUserId } : {}), ...(input.entity ? { entity: input.entity } : {}) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 101,
|
||||
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
|
||||
include: { adminUser: { select: { id: true, name: true } } },
|
||||
});
|
||||
const page = rows.slice(0, 100);
|
||||
return {
|
||||
items: page.map((r) => ({ id: r.id, at: r.createdAt, by: r.adminUser, action: r.action, entity: r.entity, entityId: r.entityId, data: r.data as Record<string, unknown> | null })),
|
||||
next: rows.length > 100 ? page.at(-1)!.id : null,
|
||||
};
|
||||
}),
|
||||
facets: superProcedure.query(async ({ ctx }) => {
|
||||
const [staff, entities] = await Promise.all([
|
||||
ctx.db.adminUser.findMany({ orderBy: { name: 'asc' }, select: { id: true, name: true } }),
|
||||
ctx.db.auditLog.groupBy({ by: ['entity'], _count: true, orderBy: { _count: { entity: 'desc' } } }),
|
||||
]);
|
||||
return { staff, entities: entities.map((e) => e.entity) };
|
||||
}),
|
||||
});
|
||||
|
||||
// ───── настройки ─────
|
||||
|
||||
export const boSettingsRouter = router({
|
||||
get: superProcedure.query(({ ctx }) => getSettings(ctx.db)),
|
||||
save: superProcedure.input(settingsSchema.partial()).mutation(async ({ ctx, input }) => {
|
||||
for (const key of SETTING_KEYS) {
|
||||
const value = input[key];
|
||||
if (value === undefined) continue;
|
||||
await ctx.db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
|
||||
}
|
||||
}),
|
||||
paymentMethods: superProcedure.query(({ ctx }) => ctx.db.paymentMethod.findMany({ orderBy: { sortOrder: 'asc' } })),
|
||||
savePaymentMethod: superProcedure
|
||||
.input(z.object({ id: z.string().optional(), code: z.string().trim().min(1).max(40), name: texts, active: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
if (id) await ctx.db.paymentMethod.update({ where: { id }, data });
|
||||
else {
|
||||
const last = await ctx.db.paymentMethod.findFirst({ orderBy: { sortOrder: 'desc' } });
|
||||
await ctx.db.paymentMethod.create({ data: { ...data, sortOrder: (last?.sortOrder ?? 0) + 1 } });
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -110,7 +110,7 @@ function baseWhere(f: ClientFilters): Prisma.ClientWhereInput {
|
||||
return and.length ? { AND: and } : {};
|
||||
}
|
||||
|
||||
async function filteredClients(db: Db, f: ClientFilters, now: Date) {
|
||||
export async function filteredClients(db: Db, f: ClientFilters, now: Date) {
|
||||
const rows = await db.client.findMany({ where: baseWhere(f), include: clientInclude, orderBy: { createdAt: 'desc' }, take: 5000 });
|
||||
const money = await revenueByClient(
|
||||
db,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { boOrdersRouter } from './orders.ts';
|
||||
import { boProvidersRouter } from './providers.ts';
|
||||
import { boCrmRouter } from './crm.ts';
|
||||
import { boPartnersRouter } from './partners.ts';
|
||||
import { boProductsRouter } from './products.ts';
|
||||
import { boBroadcastsRouter, boJournalRouter, boSettingsRouter } from './admin5.ts';
|
||||
|
||||
/** API новой админки (apps/backoffice) */
|
||||
export const backofficeRouter = router({
|
||||
@@ -20,4 +22,8 @@ export const backofficeRouter = router({
|
||||
providers: boProvidersRouter,
|
||||
crm: boCrmRouter,
|
||||
partners: boPartnersRouter,
|
||||
products: boProductsRouter,
|
||||
broadcasts: boBroadcastsRouter,
|
||||
journal: boJournalRouter,
|
||||
settings: boSettingsRouter,
|
||||
});
|
||||
@@ -337,10 +337,10 @@ export const boOrdersRouter = router({
|
||||
|
||||
/** Цена и длительность для клиента (с его скидкой по подписке) */
|
||||
quote: ordersProcedure
|
||||
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()) }))
|
||||
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()), promoCode: z.string().trim().max(40).optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { quote: q, membership } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params);
|
||||
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level };
|
||||
const { quote: q, membership, promo } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params, input.promoCode);
|
||||
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level, promo: promo ? promo.code : null };
|
||||
}),
|
||||
|
||||
slots: ordersProcedure
|
||||
@@ -359,10 +359,12 @@ export const boOrdersRouter = router({
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
paymentMethodId: z.string().optional(),
|
||||
allowBusySlot: z.boolean().default(false),
|
||||
promoCode: z.string().trim().max(40).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const o = await createOrder(ctx.svc, {
|
||||
promoCode: input.promoCode,
|
||||
clientId: input.clientId,
|
||||
serviceCode: input.service,
|
||||
params: input.params,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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 { isEditableTextKey, listRelocationItems, loadTextOverrides, moveRelocationItem, normalizePromo, saveRelocationItem } from '@barev/services';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
|
||||
|
||||
/** Продукты видят поддержка и суперадмин; меняет — суперадмин (цены и скидки — деньги) */
|
||||
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) });
|
||||
|
||||
/** Текст по ключу на 5 языках: стандартный (из переводов) и изменённый в админке */
|
||||
async function textsFor(db: import('@barev/db').Db, keys: string[]) {
|
||||
const rows = await db.textOverride.findMany({ where: { key: { in: keys } } });
|
||||
const out: Record<string, Record<Locale, { default: string | null; value: string | null }>> = {};
|
||||
for (const key of keys) {
|
||||
out[key] = Object.fromEntries(LOCALES.map((l) => [l, { default: defaultText(l, key), value: rows.find((r) => r.key === key && r.locale === l)?.value ?? null }])) as Record<Locale, { default: string | null; value: string | null }>;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const boProductsRouter = router({
|
||||
/** Направления (скидки по уровням) и услуги со всеми настройками */
|
||||
catalog: view.query(async ({ ctx }) => {
|
||||
const since = new Date(ctx.svc.now().getTime() - 30 * DAY);
|
||||
const [categories, orders30] = await Promise.all([
|
||||
ctx.db.category.findMany({ orderBy: { sortOrder: 'asc' }, include: { services: { orderBy: { sortOrder: 'asc' }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } } } }),
|
||||
ctx.db.order.groupBy({ by: ['serviceId'], where: { createdAt: { gte: since }, status: { not: 'CANCELLED' } }, _count: true }),
|
||||
]);
|
||||
const count = new Map(orders30.map((o) => [o.serviceId, o._count]));
|
||||
return categories.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
barevPlusDiscountPct: c.barevPlusDiscountPct,
|
||||
eliteDiscountPct: c.eliteDiscountPct,
|
||||
services: c.services.map((s) => {
|
||||
const pricing = pricingSchema.parse(s.pricing);
|
||||
return {
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
flow: s.flow,
|
||||
providerRole: s.providerRole,
|
||||
active: s.active,
|
||||
isOnline: s.isOnline,
|
||||
lateCancelFeePct: s.lateCancelFeePct,
|
||||
pricing,
|
||||
paramsSchema: paramsSchemaSchema.parse(s.paramsSchema),
|
||||
slotConfig: slotConfigSchema.parse(s.slotConfig),
|
||||
fromPrice: fromPrice(pricing),
|
||||
orders30: count.get(s.id) ?? 0,
|
||||
checklist: s.checklist.map((x) => ({ code: x.code, requiresPhoto: x.requiresPhoto })),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}),
|
||||
|
||||
/** Тексты для формы: название/описание услуги, доп. услуги, пункты чек-листа */
|
||||
texts: view.input(z.object({ keys: z.array(z.string().max(120)).max(200) })).query(({ ctx, input }) => textsFor(ctx.db, input.keys.filter(isEditableTextKey))),
|
||||
|
||||
/** Сохранить тексты; пустое значение — вернуть стандартный перевод */
|
||||
saveTexts: edit.input(z.array(z.object({ key: z.string().max(120), locale: z.enum(LOCALES), value: z.string().max(2000) })).max(400)).mutation(async ({ ctx, input }) => {
|
||||
for (const r of input) {
|
||||
if (!isEditableTextKey(r.key)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'TEXT_KEY_FORBIDDEN' });
|
||||
const value = r.value.trim();
|
||||
const same = value === (defaultText(r.locale, r.key) ?? '');
|
||||
if (!value || same) await ctx.db.textOverride.deleteMany({ where: { key: r.key, locale: r.locale } });
|
||||
else await ctx.db.textOverride.upsert({ where: { key_locale: { key: r.key, locale: r.locale } }, create: { key: r.key, locale: r.locale, value, updatedById: ctx.admin.id }, update: { value, updatedById: ctx.admin.id } });
|
||||
}
|
||||
// этот процесс видит правки сразу; боты и воркер — в течение минуты
|
||||
await loadTextOverrides(ctx.db);
|
||||
}),
|
||||
|
||||
saveService: edit
|
||||
.input(z.object({ id: z.string(), active: z.boolean(), lateCancelFeePct: z.number().int().min(0).max(100), pricing: pricingSchema, slotConfig: slotConfigSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.slotConfig.dayEndMinute <= input.slotConfig.dayStartMinute) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SLOT_WINDOW_INVALID' });
|
||||
const { id, ...data } = input;
|
||||
await ctx.db.service.update({ where: { id }, data });
|
||||
}),
|
||||
|
||||
saveCategory: edit
|
||||
.input(z.object({ id: z.string(), barevPlusDiscountPct: z.number().int().min(0).max(100), eliteDiscountPct: z.number().int().min(0).max(100) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
await ctx.db.category.update({ where: { id }, data });
|
||||
}),
|
||||
|
||||
/** Чек-лист услуги: новые заказы получают новый список, уже созданные — без изменений */
|
||||
saveChecklist: edit
|
||||
.input(z.object({ serviceId: z.string(), items: z.array(z.object({ code: z.string().max(60).optional(), label: z.string().max(120).optional(), requiresPhoto: z.boolean() })).max(30) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const items = input.items.map((x) => ({ ...x, code: x.code || `c_${randomBytes(3).toString('hex')}` }));
|
||||
await ctx.db.$transaction([
|
||||
ctx.db.checklistItem.deleteMany({ where: { serviceId: input.serviceId } }),
|
||||
ctx.db.checklistItem.createMany({ data: items.map((x, i) => ({ serviceId: input.serviceId, code: x.code, requiresPhoto: x.requiresPhoto, sortOrder: i })) }),
|
||||
]);
|
||||
// подпись нового пункта — русский текст (переводы можно добавить в «Текстах»)
|
||||
for (const x of items) {
|
||||
if (x.label?.trim() && !defaultText('ru', `checklist.${x.code}`)) {
|
||||
await ctx.db.textOverride.upsert({ where: { key_locale: { key: `checklist.${x.code}`, locale: 'ru' } }, create: { key: `checklist.${x.code}`, locale: 'ru', value: x.label.trim(), updatedById: ctx.admin.id }, update: { value: x.label.trim() } });
|
||||
}
|
||||
}
|
||||
await loadTextOverrides(ctx.db);
|
||||
}),
|
||||
|
||||
// ───── промокоды ─────
|
||||
|
||||
promos: view.query(async ({ ctx }) => {
|
||||
const list = await ctx.db.promoCode.findMany({ where: { kind: 'DISCOUNT' }, orderBy: [{ active: 'desc' }, { createdAt: 'desc' }] });
|
||||
const now = ctx.svc.now();
|
||||
return list.map((p) => ({
|
||||
id: p.id,
|
||||
code: p.code,
|
||||
discountPct: p.discountPct ?? 0,
|
||||
maxUses: p.maxUses,
|
||||
usedCount: p.usedCount,
|
||||
validFrom: p.validFrom,
|
||||
validUntil: p.validUntil,
|
||||
categories: p.categories,
|
||||
note: p.note,
|
||||
active: p.active,
|
||||
state: !p.active ? 'off' : p.validUntil && p.validUntil < now ? 'expired' : p.maxUses != null && p.usedCount >= p.maxUses ? 'exhausted' : p.validFrom && p.validFrom > now ? 'scheduled' : 'live',
|
||||
}));
|
||||
}),
|
||||
|
||||
savePromo: edit
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
code: z.string().min(3).max(40),
|
||||
discountPct: z.number().int().min(1).max(100),
|
||||
maxUses: z.number().int().min(1).nullable(),
|
||||
validFrom: z.date().nullable(),
|
||||
validUntil: z.date().nullable(),
|
||||
categories: z.array(z.enum(CATEGORY_CODES)),
|
||||
note: z.string().max(300).nullable(),
|
||||
active: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const code = normalizePromo(input.code);
|
||||
if (!/^[A-Z0-9_-]{3,40}$/.test(code)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'PROMO_CODE_FORMAT' });
|
||||
const clash = await ctx.db.promoCode.findUnique({ where: { code } });
|
||||
if (clash && clash.id !== input.id) throw new TRPCError({ code: 'BAD_REQUEST', message: 'PROMO_CODE_TAKEN' });
|
||||
const { id, ...rest } = input;
|
||||
const data = { ...rest, code, kind: 'DISCOUNT' as const };
|
||||
if (id) await ctx.db.promoCode.update({ where: { id }, data });
|
||||
else await ctx.db.promoCode.create({ data });
|
||||
}),
|
||||
|
||||
// ───── релокация: общий список шагов и документов ─────
|
||||
|
||||
relocation: view.query(({ ctx }) => listRelocationItems(ctx.svc)),
|
||||
saveRelocation: edit
|
||||
.input(z.object({ id: z.string().optional(), kind: z.enum(['STEP', 'DOC']), name: localized, hint: localized.nullable().optional(), active: z.boolean(), inTurnkey: z.boolean().optional() }))
|
||||
.mutation(({ ctx, input }) => saveRelocationItem(ctx.svc, input).then((x) => ({ id: x.id }))),
|
||||
moveRelocation: edit.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(({ ctx, input }) => moveRelocationItem(ctx.svc, input)),
|
||||
});
|
||||
@@ -182,8 +182,11 @@ export const clientRouter = router({
|
||||
|
||||
/** ORD-4 */
|
||||
quote: clientProcedure
|
||||
.input(z.object({ serviceCode: z.string(), params: paramsInput }))
|
||||
.query(async ({ ctx, input }) => (await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params)).quote),
|
||||
.input(z.object({ serviceCode: z.string(), params: paramsInput, promoCode: z.string().trim().max(40).optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const r = await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params, input.promoCode);
|
||||
return { ...r.quote, promo: r.promo ? { code: r.promo.code, discountPct: r.promo.discountPct } : null };
|
||||
}),
|
||||
|
||||
/** ORD-3 */
|
||||
slots: clientProcedure
|
||||
@@ -207,6 +210,8 @@ export const clientRouter = router({
|
||||
note: z.string().max(1000).optional(),
|
||||
paymentMethodId: z.string().optional(),
|
||||
repeatOfId: z.string().optional(),
|
||||
/** промокод: применится, если выгоднее скидки подписки */
|
||||
promoCode: z.string().trim().max(40).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -9,6 +9,9 @@ import { publicProcedure, router } from '../trpc.ts';
|
||||
|
||||
export const publicRouter = router({
|
||||
/** LND-5: цены «от» и скидки — для лендинга */
|
||||
/** Тексты, изменённые в админке, — Mini App подставляет их поверх своих переводов */
|
||||
texts: publicProcedure.query(({ ctx }) => ctx.db.textOverride.findMany({ select: { key: true, locale: true, value: true } })),
|
||||
|
||||
catalog: publicProcedure.query(async ({ ctx }) => {
|
||||
const [categories, settings] = await Promise.all([listCatalog(ctx.db), getSettings(ctx.db)]);
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import multipart, { type MultipartFile } from '@fastify/multipart';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||
import { getFlow } from '@barev/core';
|
||||
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend } from '@barev/services';
|
||||
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend, startTextOverrides } from '@barev/services';
|
||||
import { getFile, putFile } from '@barev/services/storage';
|
||||
import { TelegramSender } from '@barev/services/telegram';
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
@@ -278,6 +278,9 @@ app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
|
||||
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
|
||||
// тексты из админки поверх переводов (обновляются раз в минуту)
|
||||
startTextOverrides(db);
|
||||
|
||||
await ensureBucket().catch((e) => app.log.warn({ err: e }, 'S3 недоступно — загрузка фото работать не будет'));
|
||||
await app.listen({ port: env.API_PORT, host: '0.0.0.0' });
|
||||
console.log(`API: http://localhost:${env.API_PORT}`);
|
||||
@@ -19,6 +19,8 @@ import { Crm } from './pages/Crm.tsx';
|
||||
import { LeadCard } from './pages/LeadCard.tsx';
|
||||
import { AgencyCard, Agencies } from './pages/Agencies.tsx';
|
||||
import { VenueCard, Venues } from './pages/Venues.tsx';
|
||||
import { Products, ServiceEditor } from './pages/Products.tsx';
|
||||
import { Broadcasts, Journal, Settings } from './pages/Admin5.tsx';
|
||||
|
||||
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
|
||||
export interface Me {
|
||||
@@ -57,9 +59,10 @@ const NAV: NavItem[] = [
|
||||
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', roles: [], group: 'partners' },
|
||||
{ path: '/venues', key: 'nav.venues', icon: 'venues', roles: [], group: 'partners' },
|
||||
{ path: '/crm', key: 'nav.crm', icon: 'crm', roles: ['SALES'], group: 'partners' },
|
||||
{ path: '/products', key: 'nav.products', icon: 'products', stage: 5, roles: [], group: 'setup' },
|
||||
{ path: '/journal', key: 'nav.journal', icon: 'journal', stage: 5, roles: [], group: 'setup' },
|
||||
{ path: '/products', key: 'nav.products', icon: 'products', roles: ['SUPPORT'], group: 'setup' },
|
||||
{ path: '/journal', key: 'nav.journal', icon: 'journal', roles: [], group: 'setup' },
|
||||
{ path: '/team', key: 'nav.team', icon: 'team', roles: [], group: 'setup' },
|
||||
{ path: '/settings', key: 'nav.settings', icon: 'gear', roles: [], group: 'setup' },
|
||||
];
|
||||
const GROUP_KEY = { work: 'nav.groupWork', partners: 'nav.groupPartners', setup: 'nav.groupSetup' } as const;
|
||||
|
||||
@@ -89,6 +92,9 @@ function Shell({ me }: { me: Me }) {
|
||||
const agencyId = match('/agencies/:id', path)?.id;
|
||||
const venueId = match('/venues/:id', path)?.id;
|
||||
const canCrm = items.some((n) => n.path === '/crm');
|
||||
const productId = match('/products/:id', path)?.id;
|
||||
const canProducts = items.some((n) => n.path === '/products');
|
||||
const canBroadcast = isSuper || me.roles.includes('SUPPORT');
|
||||
if (path === '/') page = <Dashboard me={me} />;
|
||||
else if ((path === '/chats' || threadId) && canChat) page = <Chats me={me} threadId={threadId} />;
|
||||
else if (path === '/orders' && canOrders) page = <Orders me={me} />;
|
||||
@@ -101,6 +107,11 @@ function Shell({ me }: { me: Me }) {
|
||||
else if (agencyId && isSuper) page = <AgencyCard id={agencyId} />;
|
||||
else if (path === '/venues' && isSuper) page = <Venues />;
|
||||
else if (venueId && isSuper) page = <VenueCard id={venueId} />;
|
||||
else if (path === '/products' && canProducts) page = <Products me={me} />;
|
||||
else if (productId && canProducts) page = <ServiceEditor id={productId} me={me} />;
|
||||
else if (path === '/broadcasts' && canBroadcast) page = <Broadcasts />;
|
||||
else if (path === '/journal' && isSuper) page = <Journal />;
|
||||
else if (path === '/settings' && isSuper) page = <Settings />;
|
||||
else if (path === '/clients' && current) page = <Clients me={me} />;
|
||||
else if (clientId && items.some((n) => n.path === '/clients')) page = <ClientCard id={clientId} me={me} />;
|
||||
else if (path === '/team' && isSuper) page = <Team me={me} />;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/* Этап 5: продукты, рассылки, журнал, настройки */
|
||||
|
||||
/* текст на 5 языках */
|
||||
.bo-l5 {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.bo-l5__row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
.bo-l5__lang {
|
||||
padding-top: 11px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
.bo-l5 .bo-input::placeholder,
|
||||
.bo-l5 .bo-textarea::placeholder {
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
|
||||
/* таблица цен */
|
||||
.bo-tablewrap {
|
||||
overflow-x: auto;
|
||||
margin: 0 -6px;
|
||||
}
|
||||
.bo-ptab th {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bo-ptab td {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.bo-ptab .bo-input {
|
||||
min-width: 78px;
|
||||
min-height: 36px;
|
||||
padding: 4px 10px;
|
||||
text-align: right;
|
||||
}
|
||||
.bo-promo {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--bc-perks);
|
||||
color: var(--bc-perks-ink);
|
||||
}
|
||||
|
||||
/* рассылка */
|
||||
.bo-bcwho {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: var(--bc-blue);
|
||||
color: var(--bc-blue-ink);
|
||||
}
|
||||
|
||||
/* журнал */
|
||||
.bo-journal {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.bo-journal li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--bc-line);
|
||||
font-size: 14px;
|
||||
}
|
||||
.bo-journal li:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
.bo-journal .bo-feed__time {
|
||||
width: 110px;
|
||||
flex: none;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.bo-json {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--bo-soft);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* настройки */
|
||||
.bo-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
.bo-settings .bo-input[data-changed] {
|
||||
border-color: var(--bc-accent);
|
||||
background: #fff6f9;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.bo-journal .bo-feed__time {
|
||||
width: 80px;
|
||||
}
|
||||
.bo-settings {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const PATHS = {
|
||||
archive: 'M4 5h16v4H4zM5 9v10h14V9M10 13h4',
|
||||
bot: 'M6 9h12v10H6zM12 5v4M9.5 13.5v.01M14.5 13.5v.01M9 17h6M3 13v2M21 13v2',
|
||||
warn: 'M12 3l10 18H2zM12 10v5M12 18v.01',
|
||||
gear: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
|
||||
phone: 'M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z',
|
||||
} as const;
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
@@ -386,6 +386,195 @@ const ru = {
|
||||
'chat.notLinked': 'Рабочий бот не открыт',
|
||||
'chat.details': 'Подробнее',
|
||||
'chat.more': 'Ещё действия',
|
||||
'audit.bo.crm.addTask': 'поставил(а) задачу',
|
||||
'audit.bo.crm.setTaskDone': 'отметил(а) задачу',
|
||||
'audit.bo.crm.removeTask': 'удалил(а) задачу',
|
||||
'audit.bo.crm.remove': 'удалил(а) лид',
|
||||
'audit.bo.segments.save': 'сохранил(а) сегмент клиентов',
|
||||
'audit.bo.segments.remove': 'удалил(а) сегмент клиентов',
|
||||
'audit.bo.team.linkUrl': 'выпустил(а) ссылку для привязки Telegram',
|
||||
'audit.bo.auth.telegramLink': 'привязывает свой Telegram',
|
||||
'audit.bo.auth.logout': 'вышел(а) из админки',
|
||||
'audit.bo.clients.exportCsv': 'выгрузил(а) клиентов в Excel',
|
||||
|
||||
// ───── продукты, рассылки, журнал, настройки (этап 5) ─────
|
||||
'nav.settings': 'Настройки',
|
||||
'pr.title': 'Продукты',
|
||||
'pr.tab.services': 'Услуги и цены',
|
||||
'pr.tab.plans': 'Подписка и скидки',
|
||||
'pr.tab.promos': 'Промокоды',
|
||||
'pr.tab.relocation': 'Релокация',
|
||||
'pr.readOnly': 'Менять цены и условия может суперадмин — здесь всё видно для справки.',
|
||||
'pr.catDiscounts': 'скидки: Barev+ −{plus}%, Elite −{elite}%',
|
||||
'pr.on': 'включена',
|
||||
'pr.off': 'выключена',
|
||||
'pr.from': 'от {sum}',
|
||||
'pr.orders30': 'заказов за 30 дн: {n}',
|
||||
'pr.setup': 'Настроить',
|
||||
'pr.back': 'Все услуги',
|
||||
'pr.content': 'Наполнение',
|
||||
'pr.contentHint': 'Так услуга называется в приложении, боте и уведомлениях. Пустое поле — стандартный текст (виден серым). Боты подхватывают правку в течение минуты.',
|
||||
'pr.name': 'Название',
|
||||
'pr.description': 'Описание',
|
||||
'pr.extrasNames': 'Названия доп. услуг ({n})',
|
||||
'pr.price': 'Цена и длительность',
|
||||
'pr.fixedPrice': 'Цена, ֏',
|
||||
'pr.minutes': 'Длительность, мин',
|
||||
'pr.base': 'База, ֏',
|
||||
'pr.perRoom': '+ комната, ֏',
|
||||
'pr.perBath': '+ санузел, ֏',
|
||||
'pr.baseMin': 'База, мин',
|
||||
'pr.perRoomMin': '+ комната, мин',
|
||||
'pr.perBathMin': '+ санузел, мин',
|
||||
'pr.roomsHint': 'База — за 1 комнату и 1 санузел; дальше доплата за каждую комнату и санузел сверх первого.',
|
||||
'pr.extras': 'Доп. услуги',
|
||||
'pr.unitCol': 'Единица',
|
||||
'pr.extraPrice': 'Цена, ֏',
|
||||
'pr.extraMin': '+ мин',
|
||||
'pr.unit.item': 'за штуку',
|
||||
'pr.unit.service': 'услуга',
|
||||
'pr.unit.hour': 'за час',
|
||||
'pr.unit.halfHour': 'за 30 мин',
|
||||
'pr.priceHint': 'Новые цены действуют для новых заказов; уже оформленные заказы не меняются. Скидка подписки считается от этих цен.',
|
||||
'pr.settings': 'Запись и отмена',
|
||||
'pr.activeLabel': 'Услуга доступна клиентам',
|
||||
'pr.windowFrom': 'Запись с',
|
||||
'pr.windowTo': 'до',
|
||||
'pr.step': 'Шаг слотов, мин',
|
||||
'pr.lead': 'Не раньше чем через, ч',
|
||||
'pr.horizon': 'Запись на дней вперёд',
|
||||
'pr.lateFee': 'Удержание за позднюю отмену, %',
|
||||
'pr.discounts': 'Скидки по подписке',
|
||||
'pr.discountsHint': 'Действуют на всё направление «{cat}».',
|
||||
'pr.checklist': 'Чек-лист исполнителя',
|
||||
'pr.photo': 'фото',
|
||||
'pr.newItem': 'Новый пункт',
|
||||
'pr.checklistHint': 'Исполнитель отмечает пункты в рабочем боте; с «фото» — только с фотоотчётом. Меняется для новых заказов.',
|
||||
'pr.plans': 'Тарифы подписки',
|
||||
'pr.plansSuper': 'Цены подписки видит и меняет суперадмин.',
|
||||
'pr.plansHint': '«Скидка до» показывается на сайте и в приложении; реальные скидки — в таблице справа и в услугах.',
|
||||
'pr.discountTable': 'Скидки по направлениям',
|
||||
'pr.discountTableHint': 'Меняются в карточке любой услуги направления.',
|
||||
'pr.promoHint': 'Промокод даёт скидку на заказ. Со скидкой подписки не складывается — действует большая. Один код — один раз на клиента.',
|
||||
'pr.newPromo': 'Новый промокод',
|
||||
'pr.promo': 'Промокод',
|
||||
'pr.noPromos': 'Промокодов пока нет.',
|
||||
'pr.used': 'использован {n} из {max}',
|
||||
'pr.until': 'до {date}',
|
||||
'pr.state.live': 'действует',
|
||||
'pr.state.off': 'выключен',
|
||||
'pr.state.expired': 'истёк',
|
||||
'pr.state.exhausted': 'исчерпан',
|
||||
'pr.state.scheduled': 'ещё не начался',
|
||||
'pr.code': 'Код',
|
||||
'pr.pct': 'Скидка, %',
|
||||
'pr.validFrom': 'Действует с',
|
||||
'pr.validUntil': 'по',
|
||||
'pr.maxUses': 'Сколько раз всего',
|
||||
'pr.maxUsesHint': 'Пусто — без ограничения',
|
||||
'pr.forCats': 'На какие направления',
|
||||
'pr.forCatsHint': 'Ничего не выбрано — на все',
|
||||
'pr.promoNotePh': 'Откуда код: рассылка, блогер, партнёр',
|
||||
'pr.promoActive': 'Промокод действует',
|
||||
'pr.rel.STEP': 'Шаги переезда',
|
||||
'pr.rel.DOC': 'Документы для переезда',
|
||||
'pr.relHint.STEP': 'Новые клиенты получают план из этого списка; у текущих шаги добавляются вручную в старой админке.',
|
||||
'pr.relHint.DOC': 'Чек-лист документов в приложении клиента.',
|
||||
'pr.turnkey': 'под ключ',
|
||||
'pr.turnkeyLabel': 'Входит в пакет «под ключ»',
|
||||
'pr.docHint': 'Подсказка под названием',
|
||||
'pr.relActive': 'Показывать клиентам',
|
||||
'no.promo': 'Промокод',
|
||||
'no.promoPh': 'Если клиент назвал код',
|
||||
'no.promoOk': 'Код {code} применён',
|
||||
'no.promoWeaker': 'Скидка по подписке больше — код не нужен',
|
||||
'bc.title': 'Рассылка в бот',
|
||||
'bc.recipients': 'Получат: {n}',
|
||||
'bc.filtersHint': 'Получатели — клиенты из списка с текущими фильтрами, которые дали согласие в боте.',
|
||||
'bc.textRu': 'Текст (по-русски)',
|
||||
'bc.textPh': 'Например: «С 1 ноября — новые районы для уборки…»',
|
||||
'bc.otherLangs': 'Переводы для других языков',
|
||||
'bc.clientsN': 'клиентов: {n}',
|
||||
'bc.fallback': 'Для {langs} перевода нет — эти клиенты получат текст по-русски.',
|
||||
'bc.appButton': 'Кнопка «Открыть приложение» под сообщением',
|
||||
'bc.test': 'Прислать себе',
|
||||
'bc.testSent': 'Отправили вам в клиентский бот',
|
||||
'bc.send': 'Отправить {n}',
|
||||
'bc.confirm': 'Точно отправить {n}?',
|
||||
'bc.sureHint': 'Отменить отправленное нельзя. Нажмите ещё раз, чтобы запустить.',
|
||||
'bc.started': 'Рассылка запущена: {n} получателей',
|
||||
'bc.history': 'Рассылки',
|
||||
'bc.historyBtn': 'История рассылок',
|
||||
'bc.historyHint': 'Отправляем примерно 25 сообщений в секунду — так Telegram не блокирует бота.',
|
||||
'bc.none': 'Рассылок ещё не было. Запустить можно из списка клиентов — по любым фильтрам.',
|
||||
'bc.status.SENDING': 'идёт',
|
||||
'bc.status.DONE': 'отправлено',
|
||||
'bc.status.CANCELLED': 'остановлено',
|
||||
'bc.sent': 'доставлено {n} из {total}',
|
||||
'bc.blocked': 'заблокировали бота: {n}',
|
||||
'bc.failed': 'ошибок: {n}',
|
||||
'bc.cancel': 'Остановить',
|
||||
'bc.cancelConfirm': 'Остановить рассылку? Оставшиеся клиенты сообщение не получат.',
|
||||
'jr.title': 'Журнал действий',
|
||||
'jr.who': 'Кто',
|
||||
'jr.what': 'Раздел',
|
||||
'jr.everyone': 'Все сотрудники',
|
||||
'jr.allSections': 'Все разделы',
|
||||
'jr.days': '{n} дн',
|
||||
'jr.empty': 'За этот период действий нет.',
|
||||
'jr.system': 'Система',
|
||||
'jr.open': 'открыть',
|
||||
'jr.details': 'подробности',
|
||||
'jr.hide': 'скрыть',
|
||||
'jr.more': 'Показать ещё',
|
||||
'jr.entity.clients': 'Клиенты',
|
||||
'jr.entity.orders': 'Заказы',
|
||||
'jr.entity.providers': 'Исполнители',
|
||||
'jr.entity.chats': 'Чаты',
|
||||
'jr.entity.crm': 'CRM',
|
||||
'jr.entity.partners': 'Партнёры',
|
||||
'jr.entity.products': 'Продукты',
|
||||
'jr.entity.broadcasts': 'Рассылки',
|
||||
'jr.entity.settings': 'Настройки',
|
||||
'jr.entity.team': 'Команда',
|
||||
'jr.entity.segments': 'Сегменты',
|
||||
'jr.entity.auth': 'Вход',
|
||||
'st.title': 'Настройки',
|
||||
'st.saveN': 'Сохранить ({n})',
|
||||
'st.group.orders': 'Заказы',
|
||||
'st.group.subs': 'Подписка',
|
||||
'st.group.agencies': 'Агентства',
|
||||
'st.group.sales': 'Бонус продажников',
|
||||
'st.group.site': 'Цены на сайте',
|
||||
'st.group.other': 'Прочее',
|
||||
'st.payments': 'Способы оплаты',
|
||||
'st.payment': 'Способ оплаты',
|
||||
'st.code': 'Код',
|
||||
'st.paymentActive': 'Показывать клиентам',
|
||||
'err.PROMO_INVALID': 'Такого промокода нет или он выключен.',
|
||||
'err.PROMO_EXPIRED': 'Срок промокода истёк или он исчерпан.',
|
||||
'err.PROMO_USED': 'Клиент уже использовал этот промокод.',
|
||||
'err.PROMO_NOT_FOR_SERVICE': 'Промокод не действует на эту услугу.',
|
||||
'err.PROMO_CODE_FORMAT': 'Код — латиница, цифры, «-» и «_», от 3 символов.',
|
||||
'err.PROMO_CODE_TAKEN': 'Такой промокод уже есть.',
|
||||
'err.SLOT_WINDOW_INVALID': 'Конец окна записи должен быть позже начала.',
|
||||
'err.TELEGRAM_NOT_LINKED': 'Привяжите свой Telegram (баннер сверху), чтобы присылать себе проверку.',
|
||||
'err.TEST_UNREACHABLE': 'Не дошло: откройте клиентского бота Barev Club хотя бы раз и попробуйте снова.',
|
||||
'err.NO_RECIPIENTS': 'По этим фильтрам нет клиентов, которым можно написать.',
|
||||
'audit.bo.products.saveTexts': 'изменил(а) тексты услуги',
|
||||
'audit.bo.products.saveService': 'изменил(а) цену или настройки услуги',
|
||||
'audit.bo.products.saveCategory': 'изменил(а) скидки направления',
|
||||
'audit.bo.products.saveChecklist': 'изменил(а) чек-лист услуги',
|
||||
'audit.bo.products.savePromo': 'изменил(а) промокод',
|
||||
'audit.bo.products.saveRelocation': 'изменил(а) шаблон релокации',
|
||||
'audit.bo.products.moveRelocation': 'изменил(а) порядок шагов релокации',
|
||||
'audit.bo.broadcasts.send': 'запустил(а) рассылку',
|
||||
'audit.bo.broadcasts.test': 'отправил(а) себе тест рассылки',
|
||||
'audit.bo.broadcasts.cancel': 'остановил(а) рассылку',
|
||||
'audit.bo.settings.save': 'изменил(а) настройки',
|
||||
'audit.bo.settings.savePaymentMethod': 'изменил(а) способ оплаты',
|
||||
'audit.bo.team.create': 'добавил(а) сотрудника',
|
||||
'audit.bo.team.update': 'изменил(а) роли сотрудника',
|
||||
|
||||
// ───── партнёры и CRM (этап 4) ─────
|
||||
'crm.title': 'CRM',
|
||||
@@ -1252,6 +1441,195 @@ const en: Dict = {
|
||||
'chat.notLinked': 'Work bot not opened',
|
||||
'chat.details': 'Details',
|
||||
'chat.more': 'More actions',
|
||||
'audit.bo.crm.addTask': 'added a task',
|
||||
'audit.bo.crm.setTaskDone': 'updated a task',
|
||||
'audit.bo.crm.removeTask': 'deleted a task',
|
||||
'audit.bo.crm.remove': 'deleted a lead',
|
||||
'audit.bo.segments.save': 'saved a client segment',
|
||||
'audit.bo.segments.remove': 'deleted a client segment',
|
||||
'audit.bo.team.linkUrl': 'issued a Telegram link',
|
||||
'audit.bo.auth.telegramLink': 'is linking their Telegram',
|
||||
'audit.bo.auth.logout': 'signed out',
|
||||
'audit.bo.clients.exportCsv': 'exported clients to Excel',
|
||||
|
||||
// ───── продукты, рассылки, журнал, настройки (этап 5) ─────
|
||||
'nav.settings': 'Settings',
|
||||
'pr.title': 'Products',
|
||||
'pr.tab.services': 'Services & prices',
|
||||
'pr.tab.plans': 'Membership & discounts',
|
||||
'pr.tab.promos': 'Promo codes',
|
||||
'pr.tab.relocation': 'Relocation',
|
||||
'pr.readOnly': 'Only a superadmin can change prices and terms — shown here for reference.',
|
||||
'pr.catDiscounts': 'discounts: Barev+ −{plus}%, Elite −{elite}%',
|
||||
'pr.on': 'on',
|
||||
'pr.off': 'off',
|
||||
'pr.from': 'from {sum}',
|
||||
'pr.orders30': '{n} orders in 30 d',
|
||||
'pr.setup': 'Set up',
|
||||
'pr.back': 'All services',
|
||||
'pr.content': 'Content',
|
||||
'pr.contentHint': 'How the service is named in the app, bot and notifications. Empty = the standard text (shown in grey). Bots pick up changes within a minute.',
|
||||
'pr.name': 'Name',
|
||||
'pr.description': 'Description',
|
||||
'pr.extrasNames': 'Extras names ({n})',
|
||||
'pr.price': 'Price and duration',
|
||||
'pr.fixedPrice': 'Price, ֏',
|
||||
'pr.minutes': 'Duration, min',
|
||||
'pr.base': 'Base, ֏',
|
||||
'pr.perRoom': '+ room, ֏',
|
||||
'pr.perBath': '+ bathroom, ֏',
|
||||
'pr.baseMin': 'Base, min',
|
||||
'pr.perRoomMin': '+ room, min',
|
||||
'pr.perBathMin': '+ bathroom, min',
|
||||
'pr.roomsHint': 'Base covers 1 room and 1 bathroom; each extra room and bathroom is added on top.',
|
||||
'pr.extras': 'Extras',
|
||||
'pr.unitCol': 'Unit',
|
||||
'pr.extraPrice': 'Price, ֏',
|
||||
'pr.extraMin': '+ min',
|
||||
'pr.unit.item': 'per item',
|
||||
'pr.unit.service': 'service',
|
||||
'pr.unit.hour': 'per hour',
|
||||
'pr.unit.halfHour': 'per 30 min',
|
||||
'pr.priceHint': 'New prices apply to new orders; existing orders don’t change. Membership discounts apply on top.',
|
||||
'pr.settings': 'Booking and cancellation',
|
||||
'pr.activeLabel': 'Service available to clients',
|
||||
'pr.windowFrom': 'Booking from',
|
||||
'pr.windowTo': 'until',
|
||||
'pr.step': 'Slot step, min',
|
||||
'pr.lead': 'Not sooner than, h',
|
||||
'pr.horizon': 'Bookable days ahead',
|
||||
'pr.lateFee': 'Late cancellation fee, %',
|
||||
'pr.discounts': 'Membership discounts',
|
||||
'pr.discountsHint': 'Apply to the whole “{cat}” category.',
|
||||
'pr.checklist': 'Provider checklist',
|
||||
'pr.photo': 'photo',
|
||||
'pr.newItem': 'New item',
|
||||
'pr.checklistHint': 'The provider ticks items in the work bot; “photo” items need a photo. Applies to new orders.',
|
||||
'pr.plans': 'Membership plans',
|
||||
'pr.plansSuper': 'Plan prices are visible to the superadmin.',
|
||||
'pr.plansHint': '“Up to” is shown on the site and in the app; actual discounts are in the table and services.',
|
||||
'pr.discountTable': 'Discounts by category',
|
||||
'pr.discountTableHint': 'Change them in any service of the category.',
|
||||
'pr.promoHint': 'A promo code gives an order discount. It doesn’t stack with membership — the larger applies. One use per client.',
|
||||
'pr.newPromo': 'New promo code',
|
||||
'pr.promo': 'Promo code',
|
||||
'pr.noPromos': 'No promo codes yet.',
|
||||
'pr.used': 'used {n} of {max}',
|
||||
'pr.until': 'until {date}',
|
||||
'pr.state.live': 'live',
|
||||
'pr.state.off': 'off',
|
||||
'pr.state.expired': 'expired',
|
||||
'pr.state.exhausted': 'used up',
|
||||
'pr.state.scheduled': 'not started',
|
||||
'pr.code': 'Code',
|
||||
'pr.pct': 'Discount, %',
|
||||
'pr.validFrom': 'Valid from',
|
||||
'pr.validUntil': 'until',
|
||||
'pr.maxUses': 'Total uses',
|
||||
'pr.maxUsesHint': 'Empty — unlimited',
|
||||
'pr.forCats': 'Categories',
|
||||
'pr.forCatsHint': 'Nothing selected — all',
|
||||
'pr.promoNotePh': 'Where it’s from: newsletter, blogger, partner',
|
||||
'pr.promoActive': 'Promo code active',
|
||||
'pr.rel.STEP': 'Relocation steps',
|
||||
'pr.rel.DOC': 'Relocation documents',
|
||||
'pr.relHint.STEP': 'New clients get their plan from this list; existing clients are edited individually.',
|
||||
'pr.relHint.DOC': 'The documents checklist in the client app.',
|
||||
'pr.turnkey': 'turnkey',
|
||||
'pr.turnkeyLabel': 'Included in the turnkey package',
|
||||
'pr.docHint': 'Hint under the name',
|
||||
'pr.relActive': 'Show to clients',
|
||||
'no.promo': 'Promo code',
|
||||
'no.promoPh': 'If the client gave a code',
|
||||
'no.promoOk': 'Code {code} applied',
|
||||
'no.promoWeaker': 'The membership discount is bigger — no need for the code',
|
||||
'bc.title': 'Bot broadcast',
|
||||
'bc.recipients': 'Recipients: {n}',
|
||||
'bc.filtersHint': 'Recipients are the clients matching the current filters who gave consent in the bot.',
|
||||
'bc.textRu': 'Text (Russian)',
|
||||
'bc.textPh': 'E.g. “From 1 November — new districts for cleaning…”',
|
||||
'bc.otherLangs': 'Translations for other languages',
|
||||
'bc.clientsN': 'clients: {n}',
|
||||
'bc.fallback': 'No translation for {langs} — those clients get the Russian text.',
|
||||
'bc.appButton': '“Open the app” button under the message',
|
||||
'bc.test': 'Send to me',
|
||||
'bc.testSent': 'Sent to you in the client bot',
|
||||
'bc.send': 'Send to {n}',
|
||||
'bc.confirm': 'Really send to {n}?',
|
||||
'bc.sureHint': 'Sent messages can’t be recalled. Click again to start.',
|
||||
'bc.started': 'Broadcast started: {n} recipients',
|
||||
'bc.history': 'Broadcasts',
|
||||
'bc.historyBtn': 'Broadcast history',
|
||||
'bc.historyHint': 'We send about 25 messages per second so Telegram doesn’t throttle the bot.',
|
||||
'bc.none': 'No broadcasts yet. Start one from the clients list with any filters.',
|
||||
'bc.status.SENDING': 'sending',
|
||||
'bc.status.DONE': 'done',
|
||||
'bc.status.CANCELLED': 'stopped',
|
||||
'bc.sent': 'delivered {n} of {total}',
|
||||
'bc.blocked': 'blocked the bot: {n}',
|
||||
'bc.failed': 'errors: {n}',
|
||||
'bc.cancel': 'Stop',
|
||||
'bc.cancelConfirm': 'Stop the broadcast? Remaining clients won’t get it.',
|
||||
'jr.title': 'Activity log',
|
||||
'jr.who': 'Who',
|
||||
'jr.what': 'Section',
|
||||
'jr.everyone': 'All staff',
|
||||
'jr.allSections': 'All sections',
|
||||
'jr.days': '{n} d',
|
||||
'jr.empty': 'No activity for this period.',
|
||||
'jr.system': 'System',
|
||||
'jr.open': 'open',
|
||||
'jr.details': 'details',
|
||||
'jr.hide': 'hide',
|
||||
'jr.more': 'Show more',
|
||||
'jr.entity.clients': 'Clients',
|
||||
'jr.entity.orders': 'Orders',
|
||||
'jr.entity.providers': 'Providers',
|
||||
'jr.entity.chats': 'Chats',
|
||||
'jr.entity.crm': 'CRM',
|
||||
'jr.entity.partners': 'Partners',
|
||||
'jr.entity.products': 'Products',
|
||||
'jr.entity.broadcasts': 'Broadcasts',
|
||||
'jr.entity.settings': 'Settings',
|
||||
'jr.entity.team': 'Team',
|
||||
'jr.entity.segments': 'Segments',
|
||||
'jr.entity.auth': 'Sign-in',
|
||||
'st.title': 'Settings',
|
||||
'st.saveN': 'Save ({n})',
|
||||
'st.group.orders': 'Orders',
|
||||
'st.group.subs': 'Membership',
|
||||
'st.group.agencies': 'Agencies',
|
||||
'st.group.sales': 'Sales bonus',
|
||||
'st.group.site': 'Website prices',
|
||||
'st.group.other': 'Other',
|
||||
'st.payments': 'Payment methods',
|
||||
'st.payment': 'Payment method',
|
||||
'st.code': 'Code',
|
||||
'st.paymentActive': 'Show to clients',
|
||||
'err.PROMO_INVALID': 'No such promo code, or it’s off.',
|
||||
'err.PROMO_EXPIRED': 'The promo code has expired or is used up.',
|
||||
'err.PROMO_USED': 'The client has already used this code.',
|
||||
'err.PROMO_NOT_FOR_SERVICE': 'The code doesn’t apply to this service.',
|
||||
'err.PROMO_CODE_FORMAT': 'Code: Latin letters, digits, “-” and “_”, 3+ characters.',
|
||||
'err.PROMO_CODE_TAKEN': 'This code already exists.',
|
||||
'err.SLOT_WINDOW_INVALID': 'The booking window must end after it starts.',
|
||||
'err.TELEGRAM_NOT_LINKED': 'Link your Telegram (banner at the top) to send yourself a test.',
|
||||
'err.TEST_UNREACHABLE': 'Not delivered: open the Barev Club client bot once and try again.',
|
||||
'err.NO_RECIPIENTS': 'No clients to message with these filters.',
|
||||
'audit.bo.products.saveTexts': 'edited service texts',
|
||||
'audit.bo.products.saveService': 'edited a service’s price or settings',
|
||||
'audit.bo.products.saveCategory': 'edited category discounts',
|
||||
'audit.bo.products.saveChecklist': 'edited a service checklist',
|
||||
'audit.bo.products.savePromo': 'edited a promo code',
|
||||
'audit.bo.products.saveRelocation': 'edited a relocation template',
|
||||
'audit.bo.products.moveRelocation': 'reordered relocation steps',
|
||||
'audit.bo.broadcasts.send': 'started a broadcast',
|
||||
'audit.bo.broadcasts.test': 'sent a test broadcast',
|
||||
'audit.bo.broadcasts.cancel': 'stopped a broadcast',
|
||||
'audit.bo.settings.save': 'changed settings',
|
||||
'audit.bo.settings.savePaymentMethod': 'edited a payment method',
|
||||
'audit.bo.team.create': 'added a team member',
|
||||
'audit.bo.team.update': 'changed team roles',
|
||||
|
||||
// ───── партнёры и CRM (этап 4) ─────
|
||||
'crm.title': 'CRM',
|
||||
@@ -2115,6 +2493,195 @@ const hy: Dict = {
|
||||
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
|
||||
'chat.details': 'Մանրամասներ',
|
||||
'chat.more': 'Այլ գործողություններ',
|
||||
'audit.bo.crm.addTask': 'ավելացրեց առաջադրանք',
|
||||
'audit.bo.crm.setTaskDone': 'նշեց առաջադրանքը',
|
||||
'audit.bo.crm.removeTask': 'ջնջեց առաջադրանքը',
|
||||
'audit.bo.crm.remove': 'ջնջեց լիդը',
|
||||
'audit.bo.segments.save': 'պահպանեց սեգմենտը',
|
||||
'audit.bo.segments.remove': 'ջնջեց սեգմենտը',
|
||||
'audit.bo.team.linkUrl': 'տվեց Telegram հղում',
|
||||
'audit.bo.auth.telegramLink': 'կապում է Telegram-ը',
|
||||
'audit.bo.auth.logout': 'դուրս եկավ',
|
||||
'audit.bo.clients.exportCsv': 'արտահանեց հաճախորդներին',
|
||||
|
||||
// ───── продукты, рассылки, журнал, настройки (этап 5) ─────
|
||||
'nav.settings': 'Կարգավորումներ',
|
||||
'pr.title': 'Ապրանքներ',
|
||||
'pr.tab.services': 'Ծառայություններ և գներ',
|
||||
'pr.tab.plans': 'Բաժանորդագրություն և զեղչեր',
|
||||
'pr.tab.promos': 'Պրոմոկոդեր',
|
||||
'pr.tab.relocation': 'Տեղափոխություն',
|
||||
'pr.readOnly': 'Գները փոխում է միայն սուպերադմինը։',
|
||||
'pr.catDiscounts': 'զեղչեր՝ Barev+ −{plus}%, Elite −{elite}%',
|
||||
'pr.on': 'միացված',
|
||||
'pr.off': 'անջատված',
|
||||
'pr.from': '{sum}-ից',
|
||||
'pr.orders30': '30 օրում պատվերներ՝ {n}',
|
||||
'pr.setup': 'Կարգավորել',
|
||||
'pr.back': 'Բոլոր ծառայությունները',
|
||||
'pr.content': 'Բովանդակություն',
|
||||
'pr.contentHint': 'Այսպես է կոչվում ծառայությունը հավելվածում և բոտում։ Դատարկ՝ ստանդարտ տեքստ։',
|
||||
'pr.name': 'Անվանում',
|
||||
'pr.description': 'Նկարագրություն',
|
||||
'pr.extrasNames': 'Լրացուցիչ ծառայությունների անվանումներ ({n})',
|
||||
'pr.price': 'Գին և տևողություն',
|
||||
'pr.fixedPrice': 'Գին, ֏',
|
||||
'pr.minutes': 'Տևողություն, ր',
|
||||
'pr.base': 'Բազա, ֏',
|
||||
'pr.perRoom': '+ սենյակ, ֏',
|
||||
'pr.perBath': '+ սանհանգույց, ֏',
|
||||
'pr.baseMin': 'Բազա, ր',
|
||||
'pr.perRoomMin': '+ սենյակ, ր',
|
||||
'pr.perBathMin': '+ սանհանգույց, ր',
|
||||
'pr.roomsHint': 'Բազան՝ 1 սենյակ և 1 սանհանգույց։',
|
||||
'pr.extras': 'Լրացուցիչ',
|
||||
'pr.unitCol': 'Միավոր',
|
||||
'pr.extraPrice': 'Գին, ֏',
|
||||
'pr.extraMin': '+ ր',
|
||||
'pr.unit.item': 'հատը',
|
||||
'pr.unit.service': 'ծառայություն',
|
||||
'pr.unit.hour': 'ժամը',
|
||||
'pr.unit.halfHour': '30 րոպեն',
|
||||
'pr.priceHint': 'Նոր գները գործում են նոր պատվերների համար։',
|
||||
'pr.settings': 'Գրանցում և չեղարկում',
|
||||
'pr.activeLabel': 'Ծառայությունը հասանելի է',
|
||||
'pr.windowFrom': 'Գրանցում՝ սկսած',
|
||||
'pr.windowTo': 'մինչև',
|
||||
'pr.step': 'Քայլ, ր',
|
||||
'pr.lead': 'Ոչ շուտ քան, ժ',
|
||||
'pr.horizon': 'Օրեր առաջ',
|
||||
'pr.lateFee': 'Ուշ չեղարկման պահում, %',
|
||||
'pr.discounts': 'Բաժանորդային զեղչեր',
|
||||
'pr.discountsHint': 'Գործում են ամբողջ «{cat}» ուղղության համար։',
|
||||
'pr.checklist': 'Կատարողի ստուգացանկ',
|
||||
'pr.photo': 'լուսանկար',
|
||||
'pr.newItem': 'Նոր կետ',
|
||||
'pr.checklistHint': 'Կատարողը նշում է կետերը բոտում։',
|
||||
'pr.plans': 'Բաժանորդագրության սակագներ',
|
||||
'pr.plansSuper': 'Գները տեսնում է սուպերադմինը։',
|
||||
'pr.plansHint': '«Մինչև» զեղչը ցուցադրվում է կայքում։',
|
||||
'pr.discountTable': 'Զեղչեր ըստ ուղղության',
|
||||
'pr.discountTableHint': 'Փոխվում են ծառայության քարտում։',
|
||||
'pr.promoHint': 'Պրոմոկոդը զեղչ է տալիս։ Չի գումարվում բաժանորդային զեղչին։',
|
||||
'pr.newPromo': 'Նոր պրոմոկոդ',
|
||||
'pr.promo': 'Պրոմոկոդ',
|
||||
'pr.noPromos': 'Պրոմոկոդեր դեռ չկան։',
|
||||
'pr.used': 'օգտագործված {n}/{max}',
|
||||
'pr.until': 'մինչև {date}',
|
||||
'pr.state.live': 'գործում է',
|
||||
'pr.state.off': 'անջատված',
|
||||
'pr.state.expired': 'ժամկետանց',
|
||||
'pr.state.exhausted': 'սպառված',
|
||||
'pr.state.scheduled': 'դեռ չի սկսվել',
|
||||
'pr.code': 'Կոդ',
|
||||
'pr.pct': 'Զեղչ, %',
|
||||
'pr.validFrom': 'Գործում է՝ սկսած',
|
||||
'pr.validUntil': 'մինչև',
|
||||
'pr.maxUses': 'Ընդհանուր քանակ',
|
||||
'pr.maxUsesHint': 'Դատարկ՝ անսահմանափակ',
|
||||
'pr.forCats': 'Ուղղություններ',
|
||||
'pr.forCatsHint': 'Ոչինչ՝ բոլորը',
|
||||
'pr.promoNotePh': 'Որտեղից է կոդը',
|
||||
'pr.promoActive': 'Պրոմոկոդն ակտիվ է',
|
||||
'pr.rel.STEP': 'Տեղափոխության քայլեր',
|
||||
'pr.rel.DOC': 'Փաստաթղթեր',
|
||||
'pr.relHint.STEP': 'Նոր հաճախորդները ստանում են այս ցանկը։',
|
||||
'pr.relHint.DOC': 'Փաստաթղթերի ցանկ հավելվածում։',
|
||||
'pr.turnkey': 'բանալի հանձնած',
|
||||
'pr.turnkeyLabel': 'Մտնում է «բանալի հանձնած» փաթեթում',
|
||||
'pr.docHint': 'Հուշում',
|
||||
'pr.relActive': 'Ցույց տալ հաճախորդներին',
|
||||
'no.promo': 'Պրոմոկոդ',
|
||||
'no.promoPh': 'Եթե հաճախորդը կոդ ունի',
|
||||
'no.promoOk': '{code} կոդը կիրառված է',
|
||||
'no.promoWeaker': 'Բաժանորդային զեղչն ավելի մեծ է',
|
||||
'bc.title': 'Ուղարկում բոտով',
|
||||
'bc.recipients': 'Կստանան՝ {n}',
|
||||
'bc.filtersHint': 'Ստացողներ՝ ընթացիկ ֆիլտրերով հաճախորդներ։',
|
||||
'bc.textRu': 'Տեքստ (ռուսերեն)',
|
||||
'bc.textPh': 'Օրինակ՝ «Նոյեմբերի 1-ից…»',
|
||||
'bc.otherLangs': 'Թարգմանություններ',
|
||||
'bc.clientsN': 'հաճախորդներ՝ {n}',
|
||||
'bc.fallback': '{langs}-ի համար թարգմանություն չկա՝ կստանան ռուսերեն։',
|
||||
'bc.appButton': '«Բացել հավելվածը» կոճակ',
|
||||
'bc.test': 'Ուղարկել ինձ',
|
||||
'bc.testSent': 'Ուղարկվեց ձեզ',
|
||||
'bc.send': 'Ուղարկել {n}',
|
||||
'bc.confirm': 'Իսկապե՞ս ուղարկել {n}',
|
||||
'bc.sureHint': 'Ուղարկվածը հետ կանչել հնարավոր չէ։',
|
||||
'bc.started': 'Ուղարկումը սկսվեց՝ {n}',
|
||||
'bc.history': 'Ուղարկումներ',
|
||||
'bc.historyBtn': 'Ուղարկումների պատմություն',
|
||||
'bc.historyHint': 'Ուղարկում ենք վայրկյանում ~25 հաղորդագրություն։',
|
||||
'bc.none': 'Ուղարկումներ դեռ չեն եղել։',
|
||||
'bc.status.SENDING': 'ընթացքում',
|
||||
'bc.status.DONE': 'ուղարկված',
|
||||
'bc.status.CANCELLED': 'կանգնեցված',
|
||||
'bc.sent': 'առաքված {n}/{total}',
|
||||
'bc.blocked': 'արգելափակել են բոտը՝ {n}',
|
||||
'bc.failed': 'սխալներ՝ {n}',
|
||||
'bc.cancel': 'Կանգնեցնել',
|
||||
'bc.cancelConfirm': 'Կանգնեցնե՞լ ուղարկումը։',
|
||||
'jr.title': 'Գործողությունների մատյան',
|
||||
'jr.who': 'Ով',
|
||||
'jr.what': 'Բաժին',
|
||||
'jr.everyone': 'Բոլոր աշխատակիցները',
|
||||
'jr.allSections': 'Բոլոր բաժինները',
|
||||
'jr.days': '{n} օր',
|
||||
'jr.empty': 'Այս ժամանակահատվածում գործողություններ չկան։',
|
||||
'jr.system': 'Համակարգ',
|
||||
'jr.open': 'բացել',
|
||||
'jr.details': 'մանրամասներ',
|
||||
'jr.hide': 'թաքցնել',
|
||||
'jr.more': 'Ցույց տալ ավելին',
|
||||
'jr.entity.clients': 'Հաճախորդներ',
|
||||
'jr.entity.orders': 'Պատվերներ',
|
||||
'jr.entity.providers': 'Կատարողներ',
|
||||
'jr.entity.chats': 'Զրույցներ',
|
||||
'jr.entity.crm': 'CRM',
|
||||
'jr.entity.partners': 'Գործընկերներ',
|
||||
'jr.entity.products': 'Ապրանքներ',
|
||||
'jr.entity.broadcasts': 'Ուղարկումներ',
|
||||
'jr.entity.settings': 'Կարգավորումներ',
|
||||
'jr.entity.team': 'Թիմ',
|
||||
'jr.entity.segments': 'Սեգմենտներ',
|
||||
'jr.entity.auth': 'Մուտք',
|
||||
'st.title': 'Կարգավորումներ',
|
||||
'st.saveN': 'Պահպանել ({n})',
|
||||
'st.group.orders': 'Պատվերներ',
|
||||
'st.group.subs': 'Բաժանորդագրություն',
|
||||
'st.group.agencies': 'Գործակալություններ',
|
||||
'st.group.sales': 'Վաճառողների բոնուս',
|
||||
'st.group.site': 'Կայքի գներ',
|
||||
'st.group.other': 'Այլ',
|
||||
'st.payments': 'Վճարման եղանակներ',
|
||||
'st.payment': 'Վճարման եղանակ',
|
||||
'st.code': 'Կոդ',
|
||||
'st.paymentActive': 'Ցույց տալ',
|
||||
'err.PROMO_INVALID': 'Այդպիսի պրոմոկոդ չկա։',
|
||||
'err.PROMO_EXPIRED': 'Պրոմոկոդի ժամկետն անցել է։',
|
||||
'err.PROMO_USED': 'Հաճախորդն արդեն օգտագործել է այս կոդը։',
|
||||
'err.PROMO_NOT_FOR_SERVICE': 'Կոդը չի գործում այս ծառայության համար։',
|
||||
'err.PROMO_CODE_FORMAT': 'Կոդ՝ լատինատառ և թվեր։',
|
||||
'err.PROMO_CODE_TAKEN': 'Այս կոդն արդեն կա։',
|
||||
'err.SLOT_WINDOW_INVALID': 'Պատուհանի ավարտը պետք է լինի սկզբից հետո։',
|
||||
'err.TELEGRAM_NOT_LINKED': 'Կապեք ձեր Telegram-ը։',
|
||||
'err.TEST_UNREACHABLE': 'Չհասավ․ բացեք հաճախորդի բոտը։',
|
||||
'err.NO_RECIPIENTS': 'Այս ֆիլտրերով հաճախորդներ չկան։',
|
||||
'audit.bo.products.saveTexts': 'փոխեց ծառայության տեքստերը',
|
||||
'audit.bo.products.saveService': 'փոխեց ծառայության գինը',
|
||||
'audit.bo.products.saveCategory': 'փոխեց զեղչերը',
|
||||
'audit.bo.products.saveChecklist': 'փոխեց ստուգացանկը',
|
||||
'audit.bo.products.savePromo': 'փոխեց պրոմոկոդը',
|
||||
'audit.bo.products.saveRelocation': 'փոխեց տեղափոխության ձևանմուշը',
|
||||
'audit.bo.products.moveRelocation': 'փոխեց քայլերի հերթականությունը',
|
||||
'audit.bo.broadcasts.send': 'սկսեց ուղարկումը',
|
||||
'audit.bo.broadcasts.test': 'ուղարկեց փորձնական',
|
||||
'audit.bo.broadcasts.cancel': 'կանգնեցրեց ուղարկումը',
|
||||
'audit.bo.settings.save': 'փոխեց կարգավորումները',
|
||||
'audit.bo.settings.savePaymentMethod': 'փոխեց վճարման եղանակը',
|
||||
'audit.bo.team.create': 'ավելացրեց աշխատակից',
|
||||
'audit.bo.team.update': 'փոխեց դերերը',
|
||||
|
||||
// ───── партнёры и CRM (этап 4) ─────
|
||||
'crm.title': 'CRM',
|
||||
|
||||
@@ -3,6 +3,7 @@ import './styles.css';
|
||||
import './chats.css';
|
||||
import './orders.css';
|
||||
import './crm.css';
|
||||
import './admin5.css';
|
||||
import { ApiProvider, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/** Этап 5: рассылки, журнал действий, настройки */
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
|
||||
import { actionLabel } from './Dashboard.tsx';
|
||||
|
||||
const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const;
|
||||
type L5 = Record<(typeof LANGS)[number], string>;
|
||||
|
||||
// ───── рассылка ─────
|
||||
|
||||
/** Рассылка по текущим фильтрам списка клиентов */
|
||||
export function BroadcastDialog({ filters, onClose }: { filters: Record<string, unknown>; onClose: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const preview = useQuery(trpc.bo.broadcasts.preview.queryOptions(filters as never));
|
||||
const [texts, setTexts] = useState<L5>({ ru: '', en: '', hy: '', zh: '', es: '' });
|
||||
const [more, setMore] = useState(false);
|
||||
const [appButton, setAppButton] = useState(true);
|
||||
const [sure, setSure] = useState(false);
|
||||
const onError = (e: unknown) => toast(errText(t, e), 'error');
|
||||
const test = useMutation(trpc.bo.broadcasts.test.mutationOptions({ onSuccess: () => toast(t('bc.testSent')), onError }));
|
||||
const send = useMutation(
|
||||
trpc.bo.broadcasts.send.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.broadcasts.list.queryKey() });
|
||||
toast(t('bc.started', { n: r.total }));
|
||||
onClose();
|
||||
location.hash = '#/broadcasts';
|
||||
},
|
||||
onError,
|
||||
}),
|
||||
);
|
||||
const p = preview.data;
|
||||
const missing = p ? LANGS.filter((l) => l !== 'ru' && p.byLocale[l] > 0 && !texts[l].trim()) : [];
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
wide
|
||||
onClose={onClose}
|
||||
title={t('bc.title')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" disabled={!texts.ru.trim() || test.isPending} onClick={() => test.mutate({ text: texts.ru, appButton })}>
|
||||
{t('bc.test')}
|
||||
</button>
|
||||
<span className="bo-grow" />
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--accent"
|
||||
disabled={!texts.ru.trim() || !p?.total || send.isPending}
|
||||
onClick={() => (sure ? send.mutate({ filters: filters as never, texts, appButton }) : setSure(true))}
|
||||
>
|
||||
{sure ? t('bc.confirm', { n: p?.total ?? 0 }) : t('bc.send', { n: p?.total ?? 0 })}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="bo-bcwho">
|
||||
<I name="clients" size={20} />
|
||||
{p ? (
|
||||
<span>
|
||||
<b>{t('bc.recipients', { n: p.total })}</b>
|
||||
<span className="bo-small bo-muted" style={{ display: 'block' }}>
|
||||
{LANGS.filter((l) => p.byLocale[l] > 0)
|
||||
.map((l) => `${l.toUpperCase()} ${p.byLocale[l]}`)
|
||||
.join(' · ') || '—'}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="bo-muted">{t('ui.loading')}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="bo-hint">{t('bc.filtersHint')}</p>
|
||||
<Field label={t('bc.textRu')}>
|
||||
<textarea className="bo-textarea" style={{ minHeight: 120 }} value={texts.ru} maxLength={3500} onChange={(e) => (setTexts({ ...texts, ru: e.target.value }), setSure(false))} placeholder={t('bc.textPh')} />
|
||||
</Field>
|
||||
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => setMore((m) => !m)}>
|
||||
{t('bc.otherLangs')} {more ? '▴' : '▾'}
|
||||
</button>
|
||||
{more &&
|
||||
LANGS.filter((l) => l !== 'ru').map((l) => (
|
||||
<Field key={l} label={`${l.toUpperCase()}${p?.byLocale[l] ? ` · ${t('bc.clientsN', { n: p.byLocale[l] })}` : ''}`}>
|
||||
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={texts[l]} maxLength={3500} onChange={(e) => setTexts({ ...texts, [l]: e.target.value })} />
|
||||
</Field>
|
||||
))}
|
||||
{missing.length > 0 && <p className="bo-hint">{t('bc.fallback', { langs: missing.map((l) => l.toUpperCase()).join(', ') })}</p>}
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={appButton} onChange={(e) => setAppButton(e.target.checked)} />
|
||||
{t('bc.appButton')}
|
||||
</label>
|
||||
{sure && <p className="bo-hint" style={{ color: 'var(--bc-danger)' }}>{t('bc.sureHint')}</p>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function Broadcasts() {
|
||||
const { t, dateTime } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ ...trpc.bo.broadcasts.list.queryOptions(), refetchInterval: (q) => (q.state.data?.some((b) => b.status === 'SENDING') ? 3000 : false) });
|
||||
const cancel = useMutation(trpc.bo.broadcasts.cancel.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.broadcasts.list.queryKey() }), onError: (e) => toast(errText(t, e), 'error') }));
|
||||
return (
|
||||
<>
|
||||
<a className="bo-back" href="#/clients">
|
||||
<I name="back" size={16} />
|
||||
{t('card.back')}
|
||||
</a>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{t('bc.history')}</h1>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginBottom: 14 }}>
|
||||
{t('bc.historyHint')}
|
||||
</p>
|
||||
{!list.data ? (
|
||||
<Skeleton h={200} />
|
||||
) : list.data.length === 0 ? (
|
||||
<div className="bo-panel bo-empty">{t('bc.none')}</div>
|
||||
) : (
|
||||
<div className="bo-col" style={{ gap: 10 }}>
|
||||
{list.data.map((b) => {
|
||||
const done = b.sent + b.blocked + b.failed;
|
||||
return (
|
||||
<section key={b.id} className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<span className="bo-small bo-muted">
|
||||
{dateTime(b.createdAt)} · {b.by ?? '—'}
|
||||
</span>
|
||||
<span className={`bo-chip${b.status === 'DONE' ? ' bo-chip--ok' : b.status === 'SENDING' ? ' bo-chip--warn' : ''}`}>{t(`bc.status.${b.status}`)}</span>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 12px', whiteSpace: 'pre-wrap' }}>{b.text}</p>
|
||||
<div className="bo-bar__track" style={{ height: 8 }}>
|
||||
<div className="bo-bar__fill" style={{ width: `${b.total ? (done / b.total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
<div className="bo-small" style={{ display: 'flex', gap: 14, flexWrap: 'wrap', marginTop: 8 }}>
|
||||
<span>{t('bc.sent', { n: b.sent, total: b.total })}</span>
|
||||
{b.blocked > 0 && <span className="bo-muted">{t('bc.blocked', { n: b.blocked })}</span>}
|
||||
{b.failed > 0 && <span style={{ color: 'var(--bc-danger)' }}>{t('bc.failed', { n: b.failed })}</span>}
|
||||
{b.status === 'SENDING' && (
|
||||
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-danger)', padding: 0 }} onClick={() => confirm(t('bc.cancelConfirm')) && cancel.mutate({ id: b.id })}>
|
||||
{t('bc.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── журнал ─────
|
||||
|
||||
/** Куда ведёт запись журнала */
|
||||
function entityHref(entity: string, id: string | null) {
|
||||
if (!id) return null;
|
||||
const map: Record<string, string> = { clients: 'clients', orders: 'orders', providers: 'providers', chats: 'chats', crm: 'crm', agency: 'agencies', venue: 'venues' };
|
||||
const base = map[entity];
|
||||
return base ? `#/${base}/${id}` : null;
|
||||
}
|
||||
|
||||
export function Journal() {
|
||||
const { t, dateTime } = useT();
|
||||
const trpc = useTRPC();
|
||||
const [who, setWho] = useState('');
|
||||
const [entity, setEntity] = useState('');
|
||||
const [days, setDays] = useState(7);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const facets = useQuery(trpc.bo.journal.facets.queryOptions());
|
||||
const list = useInfiniteQuery({
|
||||
...trpc.bo.journal.list.infiniteQueryOptions({ adminUserId: who || undefined, entity: entity || undefined, days }, { getNextPageParam: (p) => p.next ?? undefined }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{t('jr.title')}</h1>
|
||||
</div>
|
||||
<div className="bo-toolbar">
|
||||
<select className="bo-select" style={{ width: 'auto' }} value={who} onChange={(e) => setWho(e.target.value)} aria-label={t('jr.who')}>
|
||||
<option value="">{t('jr.everyone')}</option>
|
||||
{facets.data?.staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="bo-select" style={{ width: 'auto' }} value={entity} onChange={(e) => setEntity(e.target.value)} aria-label={t('jr.what')}>
|
||||
<option value="">{t('jr.allSections')}</option>
|
||||
{facets.data?.entities.map((x) => (
|
||||
<option key={x} value={x}>
|
||||
{t(`jr.entity.${x}`) === `jr.entity.${x}` ? x : t(`jr.entity.${x}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="bo-pills">
|
||||
{[1, 7, 30, 90].map((d) => (
|
||||
<button key={d} type="button" className="bo-pill" aria-pressed={days === d} onClick={() => setDays(d)}>
|
||||
{t('jr.days', { n: d })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{list.isPending ? (
|
||||
<Skeleton h={300} />
|
||||
) : items.length === 0 ? (
|
||||
<div className="bo-panel bo-empty">{t('jr.empty')}</div>
|
||||
) : (
|
||||
<section className="bo-panel" style={{ padding: 8 }}>
|
||||
<ul className="bo-journal">
|
||||
{items.map((r) => {
|
||||
const href = entityHref(r.entity, r.entityId);
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<span className="bo-feed__time">{dateTime(r.at)}</span>
|
||||
{r.by ? <Avatar name={r.by.name} id={r.by.id} size={28} /> : <span style={{ width: 28 }} />}
|
||||
<span className="bo-grow" style={{ minWidth: 0, whiteSpace: 'normal' }}>
|
||||
<b>{r.by?.name ?? t('jr.system')}</b> {actionLabel(t, r.action)}
|
||||
{href && (
|
||||
<>
|
||||
{' '}
|
||||
<a href={href} className="bo-small">
|
||||
{t('jr.open')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{r.data && (
|
||||
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-ink-3)', padding: '0 0 0 8px', fontSize: 12 }} onClick={() => setOpen(open === r.id ? null : r.id)}>
|
||||
{open === r.id ? t('jr.hide') : t('jr.details')}
|
||||
</button>
|
||||
)}
|
||||
{open === r.id && <pre className="bo-json">{JSON.stringify(r.data, null, 2)}</pre>}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{list.hasNextPage && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" style={{ margin: 10 }} disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||
{t('jr.more')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── настройки ─────
|
||||
|
||||
const GROUPS: { key: string; keys: string[] }[] = [
|
||||
{ key: 'orders', keys: ['cancel.freeHours', 'offer.acceptTimeoutMinutes', 'assign.auto', 'assign.maxAutoOffers'] },
|
||||
{ key: 'subs', keys: ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'barevPlus.giftDays', 'elite.monthlyPrice', 'elite.maxDiscountPct'] },
|
||||
{ key: 'agencies', keys: ['agency.attributionMonths', 'agency.defaultCommissionPct'] },
|
||||
{ key: 'sales', keys: ['sales.bonusPerVenue', 'sales.bonusPerAgency', 'sales.bonusAgencyPct', 'sales.bonusAgencyMonths'] },
|
||||
{ key: 'site', keys: ['relocation.hotelWithPlus', 'relocation.hotelBase', 'relocation.consultation', 'docs.fromPrice', 'docs.socialCardPrice', 'residence.escortFrom', 'residence.stateFee', 'transfer.standardPrice', 'transfer.premiumPrice'] },
|
||||
{ key: 'other', keys: ['chat.closeAfterHours', 'files.retentionDays', 'consent.version'] },
|
||||
];
|
||||
|
||||
export function Settings() {
|
||||
const { t, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const s = useQuery(trpc.bo.settings.get.queryOptions());
|
||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
||||
useEffect(() => {
|
||||
if (s.data && !draft) setDraft({ ...s.data });
|
||||
}, [s.data, draft]);
|
||||
const save = useMutation(
|
||||
trpc.bo.settings.save.mutationOptions({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries();
|
||||
toast(t('prv.saved'));
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
if (!draft || !s.data) return <Skeleton h={400} />;
|
||||
const changed = Object.keys(draft).filter((k) => draft[k] !== (s.data as Record<string, unknown>)[k]);
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{t('st.title')}</h1>
|
||||
<button type="button" className="bo-btn bo-btn--accent" disabled={!changed.length || save.isPending} onClick={() => save.mutate(Object.fromEntries(changed.map((k) => [k, draft[k]])) as never)}>
|
||||
{changed.length ? t('st.saveN', { n: changed.length }) : t('ui.save')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bo-settings">
|
||||
{GROUPS.map((g) => (
|
||||
<section key={g.key} className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t(`st.group.${g.key}`)}</h2>
|
||||
</div>
|
||||
<div className="bo-col" style={{ gap: 12 }}>
|
||||
{g.keys.map((k) => {
|
||||
const v = draft[k];
|
||||
const label = shared(`admin.settings.${k.replace('.', '_')}`);
|
||||
return typeof v === 'boolean' ? (
|
||||
<label key={k} className="bo-check">
|
||||
<input type="checkbox" checked={v} onChange={(e) => setDraft({ ...draft, [k]: e.target.checked })} />
|
||||
{label}
|
||||
</label>
|
||||
) : (
|
||||
<Field key={k} label={label}>
|
||||
<input
|
||||
className="bo-input"
|
||||
inputMode={typeof v === 'number' ? 'numeric' : undefined}
|
||||
value={String(v ?? '')}
|
||||
onChange={(e) => setDraft({ ...draft, [k]: typeof v === 'number' ? Number(e.target.value.replace(/\D/g, '')) || 0 : e.target.value })}
|
||||
data-changed={changed.includes(k) || undefined}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<PaymentMethods />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentMethods() {
|
||||
const { t, locale } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const list = useQuery(trpc.bo.settings.paymentMethods.queryOptions());
|
||||
const [edit, setEdit] = useState<{ id?: string; code: string; name: L5; active: boolean } | null>(null);
|
||||
const save = useMutation(
|
||||
trpc.bo.settings.savePaymentMethod.mutationOptions({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.settings.paymentMethods.queryKey() });
|
||||
setEdit(null);
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('st.payments')}</h2>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ code: '', name: { ru: '', en: '', hy: '', zh: '', es: '' }, active: true })}>
|
||||
<I name="plus" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="bo-list">
|
||||
{(list.data ?? []).map((m) => {
|
||||
const name = m.name as L5;
|
||||
return (
|
||||
<li key={m.id} style={{ opacity: m.active ? 1 : 0.5 }}>
|
||||
<span className="bo-grow">{name[locale as keyof L5] || name.ru}</span>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ id: m.id, code: m.code, name: { ...{ ru: '', en: '', hy: '', zh: '', es: '' }, ...name }, active: m.active })}>
|
||||
{t('prv.edit')}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{edit && (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => setEdit(null)}
|
||||
title={t('st.payment')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setEdit(null)}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!edit.code.trim() || !edit.name.ru.trim() || save.isPending} onClick={() => save.mutate(edit)}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label={t('st.code')}>
|
||||
<input className="bo-input" value={edit.code} maxLength={40} onChange={(e) => setEdit({ ...edit, code: e.target.value.replace(/[^a-z0-9_]/gi, '').toLowerCase() })} placeholder="cash" />
|
||||
</Field>
|
||||
{LANGS.map((l) => (
|
||||
<Field key={l} label={l.toUpperCase()}>
|
||||
<input className="bo-input" value={edit.name[l]} maxLength={100} onChange={(e) => setEdit({ ...edit, name: { ...edit.name, [l]: e.target.value } })} />
|
||||
</Field>
|
||||
))}
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={edit.active} onChange={(e) => setEdit({ ...edit, active: e.target.checked })} />
|
||||
{t('st.paymentActive')}
|
||||
</label>
|
||||
</Dialog>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { Avatar, Dialog, download, Field, I, PlanBadge, Skeleton, sourceLabel } from '../components/kit.tsx';
|
||||
import { BroadcastDialog } from './Admin5.tsx';
|
||||
|
||||
type Plan = 'any' | 'none' | 'BAREV_PLUS' | 'ELITE';
|
||||
interface Filters {
|
||||
@@ -78,6 +79,8 @@ export function Clients({ me }: { me: Me }) {
|
||||
}),
|
||||
);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [broadcast, setBroadcast] = useState(false);
|
||||
const canBroadcast = isSuper || me.roles.includes('SUPPORT');
|
||||
|
||||
const data = list.data;
|
||||
const from = data ? page * data.pageSize + 1 : 0;
|
||||
@@ -90,10 +93,17 @@ export function Clients({ me }: { me: Me }) {
|
||||
<h1 className="bo-title">{t('clients.title')}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" title={t('clients.broadcastSoon')} disabled>
|
||||
<I name="send" size={16} />
|
||||
{t('clients.broadcast')}
|
||||
</button>
|
||||
{canBroadcast && (
|
||||
<>
|
||||
<a className="bo-btn bo-btn--ghost" href="#/broadcasts">
|
||||
{t('bc.historyBtn')}
|
||||
</a>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setBroadcast(true)}>
|
||||
<I name="send" size={16} />
|
||||
{t('clients.broadcast')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => exportCsv.mutate(f)} disabled={exportCsv.isPending}>
|
||||
<I name="download" size={16} />
|
||||
{t('clients.export')}
|
||||
@@ -339,6 +349,7 @@ export function Clients({ me }: { me: Me }) {
|
||||
)}
|
||||
|
||||
<SaveSegment open={saveOpen} onClose={() => setSaveOpen(false)} filters={f} />
|
||||
{broadcast && <BroadcastDialog filters={f as Record<string, unknown>} onClose={() => setBroadcast(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,8 +123,14 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
|
||||
const [note, setNote] = useState('');
|
||||
const [payment, setPayment] = useState('');
|
||||
const [at, setAt] = useState<{ when: Date; busy: boolean } | null>(null);
|
||||
const [promo, setPromo] = useState('');
|
||||
const [promoQ, setPromoQ] = useState('');
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setPromoQ(promo.trim()), 400);
|
||||
return () => clearTimeout(id);
|
||||
}, [promo]);
|
||||
const ready = !!service && Object.keys(params).length > 0;
|
||||
const quote = useQuery({ ...trpc.bo.orders.quote.queryOptions({ clientId, service: service?.code ?? '', params }), enabled: ready && !!clientId, placeholderData: keepPreviousData });
|
||||
const quote = useQuery({ ...trpc.bo.orders.quote.queryOptions({ clientId, service: service?.code ?? '', params, promoCode: promoQ || undefined }), enabled: ready && !!clientId, placeholderData: keepPreviousData, retry: false });
|
||||
const create = useMutation(
|
||||
trpc.bo.orders.create.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
@@ -178,6 +184,7 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
|
||||
districtId: needsAddress ? districtId : undefined,
|
||||
note: note || undefined,
|
||||
paymentMethodId: payment || undefined,
|
||||
promoCode: promoQ && !quote.isError ? promoQ : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -244,6 +251,12 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label={t('no.promo')}
|
||||
hint={promoQ ? (quote.isError ? errText(t, quote.error) : quote.data?.promo ? t('no.promoOk', { code: quote.data.promo }) : quote.data ? t('no.promoWeaker') : undefined) : undefined}
|
||||
>
|
||||
<input className="bo-input" value={promo} maxLength={40} onChange={(e) => setPromo(e.target.value.toUpperCase())} placeholder={t('no.promoPh')} style={{ maxWidth: 260 }} />
|
||||
</Field>
|
||||
<p className="bo-hint">{t('no.hint')}</p>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,837 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { 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';
|
||||
import { Dialog, Field, I, Skeleton } from '../components/kit.tsx';
|
||||
|
||||
type Tab = 'services' | 'plans' | 'promos' | 'relocation';
|
||||
const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const;
|
||||
type Lang = (typeof LANGS)[number];
|
||||
type L5 = Record<Lang, string>;
|
||||
const EMPTY: L5 = { ru: '', en: '', hy: '', zh: '', es: '' };
|
||||
const CATS = ['HOME', 'DOCS', 'LEARNING', 'PERKS'] as const;
|
||||
const num = (v: string) => v.replace(/\D/g, '');
|
||||
const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
||||
const toMin = (s: string) => {
|
||||
const [h, m] = s.split(':').map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
};
|
||||
|
||||
export function Products({ me }: { me: Me }) {
|
||||
const { t } = useT();
|
||||
const { query } = useLocation();
|
||||
const tab = (query.get('tab') as Tab) || 'services';
|
||||
const canEdit = me.roles.includes('SUPERADMIN');
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{t('pr.title')}</h1>
|
||||
</div>
|
||||
<div className="bo-ogroups" role="tablist">
|
||||
{(['services', 'plans', 'promos', 'relocation'] as const).map((x) => (
|
||||
<button key={x} type="button" role="tab" className="bo-ogroup" aria-selected={tab === x} onClick={() => navigate(x === 'services' ? '/products' : `/products?tab=${x}`, { replace: true })}>
|
||||
{t(`pr.tab.${x}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!canEdit && <p className="bo-hint" style={{ marginBottom: 12 }}>{t('pr.readOnly')}</p>}
|
||||
{tab === 'services' && <Services />}
|
||||
{tab === 'plans' && <Plans canEdit={canEdit} />}
|
||||
{tab === 'promos' && <Promos canEdit={canEdit} />}
|
||||
{tab === 'relocation' && <Relocation canEdit={canEdit} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── услуги ─────
|
||||
|
||||
function Services() {
|
||||
const { t, shared, amd } = useT();
|
||||
const trpc = useTRPC();
|
||||
const cat = useQuery(trpc.bo.products.catalog.queryOptions());
|
||||
if (!cat.data) return <Skeleton h={300} />;
|
||||
return (
|
||||
<div className="bo-col" style={{ gap: 18 }}>
|
||||
{cat.data.map((c) => (
|
||||
<section key={c.id} className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{shared(`categories.${c.code}.name`)}</h2>
|
||||
<span className="bo-small bo-muted">{t('pr.catDiscounts', { plus: c.barevPlusDiscountPct, elite: c.eliteDiscountPct })}</span>
|
||||
</div>
|
||||
<ul className="bo-list">
|
||||
{c.services.map((s) => (
|
||||
<li key={s.id} style={{ opacity: s.active ? 1 : 0.55 }}>
|
||||
<a href={`#/products/${s.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<b>{shared(`services.${s.code}.name`)}</b>
|
||||
<span className="bo-small bo-muted" style={{ display: 'block' }}>
|
||||
{[s.active ? t('pr.on') : t('pr.off'), t('pr.from', { sum: amd(s.fromPrice) }), shared(`roles.${s.providerRole}`), t('pr.orders30', { n: s.orders30 })].join(' · ')}
|
||||
</span>
|
||||
</a>
|
||||
<a className="bo-btn bo-btn--ghost bo-btn--sm" href={`#/products/${s.id}`}>
|
||||
{t('pr.setup')}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Поля на 5 языках со стандартным текстом в подсказке */
|
||||
function L5Editor({ label, value, defaults, onChange, multiline, disabled }: { label: string; value: L5; defaults: Partial<L5>; onChange: (v: L5) => void; multiline?: boolean; disabled?: boolean }) {
|
||||
return (
|
||||
<div className="bo-field">
|
||||
<span className="bo-label">{label}</span>
|
||||
<div className="bo-l5">
|
||||
{LANGS.map((l) => (
|
||||
<label key={l} className="bo-l5__row">
|
||||
<span className="bo-l5__lang">{l.toUpperCase()}</span>
|
||||
{multiline ? (
|
||||
<textarea className="bo-textarea" style={{ minHeight: 60 }} disabled={disabled} value={value[l]} placeholder={defaults[l] ?? ''} onChange={(e) => onChange({ ...value, [l]: e.target.value })} />
|
||||
) : (
|
||||
<input className="bo-input" disabled={disabled} value={value[l]} placeholder={defaults[l] ?? ''} onChange={(e) => onChange({ ...value, [l]: e.target.value })} />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Catalog = NonNullable<ReturnType<typeof useCatalog>>;
|
||||
function useCatalog() {
|
||||
const trpc = useTRPC();
|
||||
return useQuery(trpc.bo.products.catalog.queryOptions()).data;
|
||||
}
|
||||
|
||||
export function ServiceEditor({ id, me }: { id: string; me: Me }) {
|
||||
const { t, shared } = useT();
|
||||
const cat = useCatalog();
|
||||
const canEdit = me.roles.includes('SUPERADMIN');
|
||||
const found = useMemo(() => {
|
||||
for (const c of cat ?? []) for (const s of c.services) if (s.id === id) return { c, s };
|
||||
return null;
|
||||
}, [cat, id]);
|
||||
const back = (
|
||||
<a className="bo-back" href="#/products">
|
||||
<I name="back" size={16} />
|
||||
{t('pr.back')}
|
||||
</a>
|
||||
);
|
||||
if (!cat) return <Skeleton h={300} />;
|
||||
if (!found) return back;
|
||||
const { c, s } = found;
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<div className="bo-head">
|
||||
<div>
|
||||
<h1 className="bo-title">{shared(`services.${s.code}.name`)}</h1>
|
||||
<p className="bo-sub">{[shared(`categories.${c.code}.name`), shared(`roles.${s.providerRole}`), s.isOnline ? shared('common.online') : null].filter(Boolean).join(' · ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bo-client-grid">
|
||||
<div className="bo-col">
|
||||
<ContentPanel code={s.code} canEdit={canEdit} extras={s.pricing.kind === 'rooms' ? Object.keys(s.pricing.extras) : []} />
|
||||
</div>
|
||||
<div className="bo-col">
|
||||
<SettingsPanel service={s} canEdit={canEdit} />
|
||||
<CategoryPanel category={c} canEdit={canEdit} />
|
||||
<ChecklistPanel service={s} canEdit={canEdit} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<PricePanel service={s} canEdit={canEdit} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useSaved() {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
return {
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.products.texts.queryKey() });
|
||||
toast(t('prv.saved'));
|
||||
},
|
||||
onError: (e: unknown) => toast(errText(t, e), 'error'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Наполнение: название и описание услуги, названия доп. услуг */
|
||||
function ContentPanel({ code, canEdit, extras }: { code: string; canEdit: boolean; extras: string[] }) {
|
||||
const { t, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const keys = [`services.${code}.name`, `services.${code}.description`, ...extras.map((x) => `extras.${x}.name`)];
|
||||
const texts = useQuery(trpc.bo.products.texts.queryOptions({ keys }));
|
||||
const [draft, setDraft] = useState<Record<string, L5> | null>(null);
|
||||
useEffect(() => {
|
||||
if (texts.data && !draft) setDraft(Object.fromEntries(keys.map((k) => [k, Object.fromEntries(LANGS.map((l) => [l, texts.data[k]?.[l]?.value ?? ''])) as L5])));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [texts.data]);
|
||||
const save = useMutation(trpc.bo.products.saveTexts.mutationOptions(useSaved()));
|
||||
const defaults = (k: string) => Object.fromEntries(LANGS.map((l) => [l, texts.data?.[k]?.[l]?.default ?? ''])) as L5;
|
||||
const [showExtras, setShowExtras] = useState(false);
|
||||
if (!draft) return <Skeleton h={200} />;
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('pr.content')}</h2>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginBottom: 12 }}>
|
||||
{t('pr.contentHint')}
|
||||
</p>
|
||||
<div className="bo-col" style={{ gap: 14 }}>
|
||||
<L5Editor label={t('pr.name')} value={draft[keys[0]!]!} defaults={defaults(keys[0]!)} onChange={(v) => setDraft({ ...draft, [keys[0]!]: v })} disabled={!canEdit} />
|
||||
<L5Editor label={t('pr.description')} value={draft[keys[1]!]!} defaults={defaults(keys[1]!)} onChange={(v) => setDraft({ ...draft, [keys[1]!]: v })} multiline disabled={!canEdit} />
|
||||
{extras.length > 0 && (
|
||||
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => setShowExtras((x) => !x)}>
|
||||
{t('pr.extrasNames', { n: extras.length })} {showExtras ? '▴' : '▾'}
|
||||
</button>
|
||||
)}
|
||||
{showExtras &&
|
||||
extras.map((x) => {
|
||||
const k = `extras.${x}.name`;
|
||||
return <L5Editor key={k} label={shared(k)} value={draft[k]!} defaults={defaults(k)} onChange={(v) => setDraft({ ...draft, [k]: v })} disabled={!canEdit} />;
|
||||
})}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--sm"
|
||||
style={{ marginTop: 14 }}
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate(keys.flatMap((k) => LANGS.map((l) => ({ key: k, locale: l, value: draft[k]![l] }))))}
|
||||
>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type Service = Catalog[number]['services'][number];
|
||||
|
||||
/** Цена: тарифы по варианту (комнаты, санузлы) и доп. услуги — или фиксированная цена */
|
||||
function PricePanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
|
||||
const { t, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const [p, setP] = useState(s.pricing);
|
||||
useEffect(() => setP(s.pricing), [s.pricing]);
|
||||
const save = useMutation(trpc.bo.products.saveService.mutationOptions(useSaved()));
|
||||
const unitLabel = (u: string) => t(`pr.unit.${u}`);
|
||||
const variantLabel = (v: string) => {
|
||||
if (p.kind !== 'rooms') return v;
|
||||
const k = `params.${s.code}.${p.variantParam}.${v}`;
|
||||
const r = shared(k);
|
||||
return r === k ? v : r;
|
||||
};
|
||||
const n = (v: number, on: (x: number) => void) => <input className="bo-input bo-num" inputMode="numeric" disabled={!canEdit} value={String(v)} onChange={(e) => on(Number(num(e.target.value)) || 0)} />;
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('pr.price')}</h2>
|
||||
</div>
|
||||
{p.kind === 'fixed' ? (
|
||||
<div className="bo-row2">
|
||||
<Field label={t('pr.fixedPrice')}>{n(p.price, (x) => setP({ ...p, price: x }))}</Field>
|
||||
<Field label={t('pr.minutes')}>{n(p.minutes, (x) => setP({ ...p, minutes: Math.max(1, x) }))}</Field>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bo-tablewrap">
|
||||
<table className="bo-table bo-ptab">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t('pr.base')}</th>
|
||||
<th>{t('pr.perRoom')}</th>
|
||||
<th>{t('pr.perBath')}</th>
|
||||
<th>{t('pr.baseMin')}</th>
|
||||
<th>{t('pr.perRoomMin')}</th>
|
||||
<th>{t('pr.perBathMin')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(p.tiers).map(([v, tier]) => {
|
||||
const set = (patch: Partial<typeof tier>) => setP({ ...p, tiers: { ...p.tiers, [v]: { ...tier, ...patch } } });
|
||||
return (
|
||||
<tr key={v}>
|
||||
<td>
|
||||
<b>{variantLabel(v)}</b>
|
||||
</td>
|
||||
<td>{n(tier.base, (x) => set({ base: x }))}</td>
|
||||
<td>{n(tier.perRoom, (x) => set({ perRoom: x }))}</td>
|
||||
<td>{n(tier.perBathroom, (x) => set({ perBathroom: x }))}</td>
|
||||
<td>{n(tier.baseMinutes, (x) => set({ baseMinutes: Math.max(1, x) }))}</td>
|
||||
<td>{n(tier.perRoomMinutes, (x) => set({ perRoomMinutes: x }))}</td>
|
||||
<td>{n(tier.perBathroomMinutes, (x) => set({ perBathroomMinutes: x }))}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ margin: '8px 0 16px' }}>
|
||||
{t('pr.roomsHint')}
|
||||
</p>
|
||||
<h3 className="bo-h3" style={{ marginBottom: 8 }}>
|
||||
{t('pr.extras')}
|
||||
</h3>
|
||||
<div className="bo-tablewrap">
|
||||
<table className="bo-table bo-ptab">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t('pr.unitCol')}</th>
|
||||
<th>{t('pr.extraPrice')}</th>
|
||||
<th>{t('pr.extraMin')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(p.extras).map(([code, ex]) => {
|
||||
const set = (patch: Partial<typeof ex>) => setP({ ...p, extras: { ...p.extras, [code]: { ...ex, ...patch } } });
|
||||
return (
|
||||
<tr key={code}>
|
||||
<td>{shared(`extras.${code}.name`)}</td>
|
||||
<td className="bo-small bo-muted">{unitLabel(ex.unit)}</td>
|
||||
<td>{n(ex.price, (x) => set({ price: x }))}</td>
|
||||
<td>{n(ex.minutes, (x) => set({ minutes: x }))}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 14 }} disabled={save.isPending} onClick={() => save.mutate({ id: s.id, active: s.active, lateCancelFeePct: s.lateCancelFeePct, pricing: p, slotConfig: s.slotConfig })}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
)}
|
||||
<p className="bo-hint" style={{ marginTop: 10 }}>
|
||||
{t('pr.priceHint')}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Включена ли услуга, окно записи, удержание за позднюю отмену */
|
||||
function SettingsPanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const [f, setF] = useState({ active: s.active, fee: String(s.lateCancelFeePct), from: hhmm(s.slotConfig.dayStartMinute), to: hhmm(s.slotConfig.dayEndMinute), step: String(s.slotConfig.stepMinutes), lead: String(s.slotConfig.leadHours), horizon: String(s.slotConfig.horizonDays) });
|
||||
const save = useMutation(trpc.bo.products.saveService.mutationOptions(useSaved()));
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('pr.settings')}</h2>
|
||||
</div>
|
||||
<div className="bo-col" style={{ gap: 12 }}>
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" disabled={!canEdit} checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
|
||||
{t('pr.activeLabel')}
|
||||
</label>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('pr.windowFrom')}>
|
||||
<input className="bo-input" type="time" step={900} disabled={!canEdit} value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('pr.windowTo')}>
|
||||
<input className="bo-input" type="time" step={900} disabled={!canEdit} value={f.to} onChange={(e) => setF({ ...f, to: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('pr.step')}>
|
||||
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.step} onChange={(e) => setF({ ...f, step: num(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label={t('pr.lead')}>
|
||||
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.lead} onChange={(e) => setF({ ...f, lead: num(e.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('pr.horizon')}>
|
||||
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.horizon} onChange={(e) => setF({ ...f, horizon: num(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label={t('pr.lateFee')}>
|
||||
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.fee} onChange={(e) => setF({ ...f, fee: num(e.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--sm"
|
||||
style={{ marginTop: 14 }}
|
||||
disabled={save.isPending}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
id: s.id,
|
||||
active: f.active,
|
||||
lateCancelFeePct: Math.min(100, Number(f.fee) || 0),
|
||||
pricing: s.pricing,
|
||||
slotConfig: { dayStartMinute: toMin(f.from), dayEndMinute: toMin(f.to), stepMinutes: Math.max(15, Number(f.step) || 30), leadHours: Number(f.lead) || 0, horizonDays: Math.max(1, Number(f.horizon) || 14) },
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Скидки по уровню подписки — на всё направление */
|
||||
function CategoryPanel({ category: c, canEdit }: { category: Catalog[number]; canEdit: boolean }) {
|
||||
const { t, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const [plus, setPlus] = useState(String(c.barevPlusDiscountPct));
|
||||
const [elite, setElite] = useState(String(c.eliteDiscountPct));
|
||||
const save = useMutation(trpc.bo.products.saveCategory.mutationOptions(useSaved()));
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('pr.discounts')}</h2>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginBottom: 10 }}>
|
||||
{t('pr.discountsHint', { cat: shared(`categories.${c.code}.name`) })}
|
||||
</p>
|
||||
<div className="bo-row2">
|
||||
<Field label="Barev+, %">
|
||||
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={plus} onChange={(e) => setPlus(num(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Barev Elite, %">
|
||||
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={elite} onChange={(e) => setElite(num(e.target.value))} />
|
||||
</Field>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 12 }} disabled={save.isPending} onClick={() => save.mutate({ id: c.id, barevPlusDiscountPct: Math.min(100, Number(plus) || 0), eliteDiscountPct: Math.min(100, Number(elite) || 0) })}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Чек-лист исполнителя: пункты и где нужно фото */
|
||||
function ChecklistPanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
|
||||
const { t, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const [items, setItems] = useState<{ code?: string; label?: string; requiresPhoto: boolean }[]>(s.checklist);
|
||||
useEffect(() => setItems(s.checklist), [s.checklist]);
|
||||
const [label, setLabel] = useState('');
|
||||
const save = useMutation(trpc.bo.products.saveChecklist.mutationOptions(useSaved()));
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('pr.checklist')}</h2>
|
||||
</div>
|
||||
<ul className="bo-list">
|
||||
{items.map((x, i) => (
|
||||
<li key={x.code ?? `new-${i}`}>
|
||||
<span className="bo-grow">{x.code ? shared(`checklist.${x.code}`) : x.label}</span>
|
||||
<label className="bo-check bo-small">
|
||||
<input type="checkbox" disabled={!canEdit} checked={x.requiresPhoto} onChange={(e) => setItems(items.map((y, k) => (k === i ? { ...y, requiresPhoto: e.target.checked } : y)))} />
|
||||
{t('pr.photo')}
|
||||
</label>
|
||||
{canEdit && (
|
||||
<button type="button" className="bo-iconbtn" onClick={() => setItems(items.filter((_, k) => k !== i))} aria-label={t('chat.tplDelete')}>
|
||||
<I name="close" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{canEdit && (
|
||||
<>
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!label.trim()) return;
|
||||
setItems([...items, { label: label.trim(), requiresPhoto: false }]);
|
||||
setLabel('');
|
||||
}}
|
||||
>
|
||||
<input className="bo-input" value={label} maxLength={120} onChange={(e) => setLabel(e.target.value)} placeholder={t('pr.newItem')} style={{ minHeight: 36, padding: '6px 12px' }} />
|
||||
<button type="submit" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={!label.trim()}>
|
||||
<I name="plus" size={16} />
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 12 }} disabled={save.isPending} onClick={() => save.mutate({ serviceId: s.id, items })}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<p className="bo-hint" style={{ marginTop: 10 }}>
|
||||
{t('pr.checklistHint')}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── подписка ─────
|
||||
|
||||
const PLAN_KEYS = ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'barevPlus.giftDays', 'elite.monthlyPrice', 'elite.maxDiscountPct'] as const;
|
||||
|
||||
function Plans({ canEdit }: { canEdit: boolean }) {
|
||||
const { t, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const settings = useQuery({ ...trpc.bo.settings.get.queryOptions(), enabled: canEdit });
|
||||
const cat = useCatalog();
|
||||
const [f, setF] = useState<Record<string, string> | null>(null);
|
||||
useEffect(() => {
|
||||
if (settings.data && !f) setF(Object.fromEntries(PLAN_KEYS.map((k) => [k, String(settings.data[k])])));
|
||||
}, [settings.data, f]);
|
||||
const save = useMutation(
|
||||
trpc.bo.settings.save.mutationOptions({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.settings.get.queryKey() });
|
||||
toast(t('prv.saved'));
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<div className="bo-client-grid">
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('pr.plans')}</h2>
|
||||
</div>
|
||||
{!canEdit ? (
|
||||
<p className="bo-hint">{t('pr.plansSuper')}</p>
|
||||
) : !f ? (
|
||||
<Skeleton h={200} />
|
||||
) : (
|
||||
<>
|
||||
<div className="bo-col" style={{ gap: 12 }}>
|
||||
{PLAN_KEYS.map((k) => (
|
||||
<Field key={k} label={shared(`admin.settings.${k.replace('.', '_')}`)}>
|
||||
<input className="bo-input" inputMode="numeric" value={f[k]} onChange={(e) => setF({ ...f, [k]: num(e.target.value) })} />
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 14 }} disabled={save.isPending} onClick={() => save.mutate(Object.fromEntries(PLAN_KEYS.map((k) => [k, Number(f[k]) || 0])))}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
<p className="bo-hint" style={{ marginTop: 10 }}>
|
||||
{t('pr.plansHint')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('pr.discountTable')}</h2>
|
||||
</div>
|
||||
{!cat ? (
|
||||
<Skeleton h={160} />
|
||||
) : (
|
||||
<table className="bo-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th className="bo-r">Barev+</th>
|
||||
<th className="bo-r">Elite</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cat.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>{shared(`categories.${c.code}.name`)}</td>
|
||||
<td className="bo-r bo-num">−{c.barevPlusDiscountPct}%</td>
|
||||
<td className="bo-r bo-num">−{c.eliteDiscountPct}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="bo-hint" style={{ marginTop: 10 }}>
|
||||
{t('pr.discountTableHint')}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── промокоды ─────
|
||||
|
||||
type Promo = { id: string; code: string; discountPct: number; maxUses: number | null; usedCount: number; validFrom: Date | null; validUntil: Date | null; categories: string[]; note: string | null; active: boolean; state: string };
|
||||
|
||||
function Promos({ canEdit }: { canEdit: boolean }) {
|
||||
const { t, date, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const list = useQuery(trpc.bo.products.promos.queryOptions());
|
||||
const [edit, setEdit] = useState<Promo | 'new' | null>(null);
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<p className="bo-hint" style={{ margin: 0, maxWidth: 560 }}>
|
||||
{t('pr.promoHint')}
|
||||
</p>
|
||||
{canEdit && (
|
||||
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setEdit('new')}>
|
||||
<I name="plus" size={16} />
|
||||
{t('pr.newPromo')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!list.data ? (
|
||||
<Skeleton h={160} />
|
||||
) : list.data.length === 0 ? (
|
||||
<p className="bo-empty">{t('pr.noPromos')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{list.data.map((p) => (
|
||||
<li key={p.id}>
|
||||
<span className="bo-promo">{p.code}</span>
|
||||
<span className="bo-grow" style={{ minWidth: 0 }}>
|
||||
<b className="bo-num">−{p.discountPct}%</b>
|
||||
<span className="bo-small bo-muted">
|
||||
{' '}
|
||||
· {t('pr.used', { n: p.usedCount, max: p.maxUses ?? '∞' })}
|
||||
{p.validUntil && ` · ${t('pr.until', { date: date(p.validUntil) })}`}
|
||||
{p.categories.length > 0 && ` · ${p.categories.map((c) => shared(`categories.${c}.name`)).join(', ')}`}
|
||||
{p.note && ` · ${p.note}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`bo-chip${p.state === 'live' ? ' bo-chip--ok' : ''}`}>{t(`pr.state.${p.state}`)}</span>
|
||||
{canEdit && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit(p)}>
|
||||
{t('prv.edit')}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{edit && <PromoDialog promo={edit === 'new' ? undefined : edit} onClose={() => setEdit(null)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoDialog({ promo, onClose }: { promo?: Promo; onClose: () => void }) {
|
||||
const { t, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const day = (d: Date | null) => (d ? new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 10) : '');
|
||||
const [f, setF] = useState({
|
||||
code: promo?.code ?? '',
|
||||
pct: String(promo?.discountPct ?? 10),
|
||||
max: promo?.maxUses != null ? String(promo.maxUses) : '',
|
||||
from: day(promo?.validFrom ?? null),
|
||||
until: day(promo?.validUntil ?? null),
|
||||
categories: (promo?.categories ?? []) as (typeof CATS)[number][],
|
||||
note: promo?.note ?? '',
|
||||
active: promo?.active ?? true,
|
||||
});
|
||||
const save = useMutation(
|
||||
trpc.bo.products.savePromo.mutationOptions({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.products.promos.queryKey() });
|
||||
toast(t('prv.saved'));
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={promo ? t('pr.promo') : t('pr.newPromo')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn"
|
||||
disabled={f.code.trim().length < 3 || !Number(f.pct) || save.isPending}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
id: promo?.id,
|
||||
code: f.code,
|
||||
discountPct: Math.min(100, Number(f.pct)),
|
||||
maxUses: f.max ? Number(f.max) : null,
|
||||
validFrom: f.from ? new Date(`${f.from}T00:00:00+04:00`) : null,
|
||||
validUntil: f.until ? new Date(`${f.until}T23:59:59+04:00`) : null,
|
||||
categories: f.categories,
|
||||
note: f.note || null,
|
||||
active: f.active,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('pr.code')}>
|
||||
<input className="bo-input" value={f.code} maxLength={40} onChange={(e) => setF({ ...f, code: e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, '') })} placeholder="AUTUMN15" />
|
||||
</Field>
|
||||
<Field label={t('pr.pct')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.pct} onChange={(e) => setF({ ...f, pct: num(e.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('pr.validFrom')}>
|
||||
<input className="bo-input" type="date" value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('pr.validUntil')}>
|
||||
<input className="bo-input" type="date" value={f.until} onChange={(e) => setF({ ...f, until: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('pr.maxUses')} hint={t('pr.maxUsesHint')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.max} onChange={(e) => setF({ ...f, max: num(e.target.value) })} placeholder="∞" />
|
||||
</Field>
|
||||
<div>
|
||||
<span className="bo-label">{t('pr.forCats')}</span>
|
||||
<div className="bo-pills" style={{ marginTop: 6 }}>
|
||||
{CATS.map((c) => (
|
||||
<button key={c} type="button" className="bo-pill" aria-pressed={f.categories.includes(c)} onClick={() => setF({ ...f, categories: f.categories.includes(c) ? f.categories.filter((x) => x !== c) : [...f.categories, c] })}>
|
||||
{shared(`categories.${c}.name`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="bo-hint">{t('pr.forCatsHint')}</span>
|
||||
</div>
|
||||
<Field label={t('crm.note')}>
|
||||
<input className="bo-input" value={f.note} maxLength={300} onChange={(e) => setF({ ...f, note: e.target.value })} placeholder={t('pr.promoNotePh')} />
|
||||
</Field>
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
|
||||
{t('pr.promoActive')}
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── релокация ─────
|
||||
|
||||
type RelItem = { id: string; kind: 'STEP' | 'DOC'; code: string; name: unknown; hint: unknown; active: boolean; inTurnkey: boolean };
|
||||
|
||||
function Relocation({ canEdit }: { canEdit: boolean }) {
|
||||
const { t, locale } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const list = useQuery(trpc.bo.products.relocation.queryOptions());
|
||||
const move = useMutation(trpc.bo.products.moveRelocation.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.products.relocation.queryKey() }), onError: (e) => toast(errText(t, e), 'error') }));
|
||||
const [edit, setEdit] = useState<{ kind: 'STEP' | 'DOC'; item?: RelItem } | null>(null);
|
||||
const name = (v: unknown) => {
|
||||
const o = (v ?? {}) as Record<string, string>;
|
||||
return o[locale] || o.ru || '';
|
||||
};
|
||||
return (
|
||||
<div className="bo-client-grid">
|
||||
{(['STEP', 'DOC'] as const).map((kind) => (
|
||||
<section key={kind} className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t(`pr.rel.${kind}`)}</h2>
|
||||
{canEdit && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ kind })}>
|
||||
<I name="plus" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ul className="bo-list">
|
||||
{(list.data ?? [])
|
||||
.filter((x) => x.kind === kind)
|
||||
.map((x) => (
|
||||
<li key={x.id} style={{ opacity: x.active ? 1 : 0.5 }}>
|
||||
{canEdit && (
|
||||
<span className="bo-stages__arrows">
|
||||
<button type="button" onClick={() => move.mutate({ id: x.id, dir: -1 })} aria-label="↑">
|
||||
<I name="arrowUp" size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => move.mutate({ id: x.id, dir: 1 })} aria-label="↓">
|
||||
<I name="arrowDown" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
|
||||
{name(x.name)}
|
||||
{kind === 'STEP' && x.inTurnkey && <span className="bo-chip" style={{ marginLeft: 8 }}>{t('pr.turnkey')}</span>}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ kind, item: x as RelItem })}>
|
||||
{t('prv.edit')}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="bo-hint" style={{ marginTop: 10 }}>
|
||||
{t(`pr.relHint.${kind}`)}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
{edit && <RelocationDialog kind={edit.kind} item={edit.item} onClose={() => setEdit(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelocationDialog({ kind, item, onClose }: { kind: 'STEP' | 'DOC'; item?: RelItem; onClose: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [name, setName] = useState<L5>({ ...EMPTY, ...((item?.name as Partial<L5>) ?? {}) });
|
||||
const [hint, setHint] = useState<L5>({ ...EMPTY, ...((item?.hint as Partial<L5>) ?? {}) });
|
||||
const [active, setActive] = useState(item?.active ?? true);
|
||||
const [turnkey, setTurnkey] = useState(item?.inTurnkey ?? true);
|
||||
const save = useMutation(
|
||||
trpc.bo.products.saveRelocation.mutationOptions({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.products.relocation.queryKey() });
|
||||
toast(t('prv.saved'));
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
wide
|
||||
onClose={onClose}
|
||||
title={t(`pr.rel.${kind}`)}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!name.ru.trim() || save.isPending} onClick={() => save.mutate({ id: item?.id, kind, name, hint: kind === 'DOC' ? hint : null, active, inTurnkey: kind === 'STEP' ? turnkey : false })}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<L5Editor label={t('pr.name')} value={name} defaults={{}} onChange={setName} />
|
||||
{kind === 'DOC' && <L5Editor label={t('pr.docHint')} value={hint} defaults={{}} onChange={setHint} />}
|
||||
{kind === 'STEP' && (
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={turnkey} onChange={(e) => setTurnkey(e.target.checked)} />
|
||||
{t('pr.turnkeyLabel')}
|
||||
</label>
|
||||
)}
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||
{t('pr.relActive')}
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { getDb } from '@barev/db';
|
||||
import { createT, formatDateTime, isLocale, LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { decodeStartPayload } from '@barev/core';
|
||||
import {
|
||||
startTextOverrides,
|
||||
addClientMessage,
|
||||
applyAgencyCode,
|
||||
DomainError,
|
||||
@@ -32,6 +33,7 @@ if (!token) console.log('CLIENT_BOT_TOKEN не задан — клиентски
|
||||
const db = getDb();
|
||||
const notifier = createQueueNotifier(process.env.REDIS_URL ?? 'redis://localhost:6379');
|
||||
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
|
||||
startTextOverrides(db);
|
||||
const APP_URL = process.env.CLIENT_MINIAPP_URL ?? '';
|
||||
const LANDING = process.env.LANDING_URL ?? '';
|
||||
const bot = new Bot(token || 'disabled:token');
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { getDb } from '@barev/db';
|
||||
import { createT, formatDateTime, type Locale } from '@barev/i18n';
|
||||
import {
|
||||
startTextOverrides,
|
||||
acceptPartnerInvite,
|
||||
addProviderMessage,
|
||||
checkCard,
|
||||
@@ -32,6 +33,7 @@ if (!token) console.log('PRO_BOT_TOKEN не задан — бот исполни
|
||||
const db = getDb();
|
||||
const notifier = createQueueNotifier(process.env.REDIS_URL ?? 'redis://localhost:6379');
|
||||
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
|
||||
startTextOverrides(db);
|
||||
const APP_URL = process.env.PRO_MINIAPP_URL ?? '';
|
||||
const bot = new Bot(token || 'disabled:token');
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import './app.css';
|
||||
import { ApiProvider, getInitData, initTelegram, ToastProvider } from '@barev/web';
|
||||
import { ApiProvider, loadTextOverrides, getInitData, initTelegram, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
|
||||
initTelegram();
|
||||
// названия и описания услуг, изменённые в админке
|
||||
void loadTextOverrides();
|
||||
const initData = getInitData();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import './app.css';
|
||||
import { ApiProvider, initTelegram, ToastProvider } from '@barev/web';
|
||||
import { ApiProvider, loadTextOverrides, initTelegram, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import { authHeader, hasInitData } from './auth.ts';
|
||||
|
||||
initTelegram();
|
||||
// названия и описания услуг, изменённые в админке
|
||||
void loadTextOverrides();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { getDb } from '@barev/db';
|
||||
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
|
||||
import { autoAssign, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
|
||||
import { autoAssign, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
|
||||
import { getFile } from '@barev/services/storage';
|
||||
import { loadRootEnv } from '@barev/services/env';
|
||||
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
|
||||
@@ -17,6 +17,7 @@ const db = getDb();
|
||||
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
|
||||
const notifier = createQueueNotifier(REDIS_URL);
|
||||
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
|
||||
startTextOverrides(db);
|
||||
const clientBot = new TelegramSender(process.env.CLIENT_BOT_TOKEN ?? '', 'client-bot');
|
||||
const proBot = new TelegramSender(process.env.PRO_BOT_TOKEN ?? '', 'pro-bot');
|
||||
const CLIENT_APP = process.env.CLIENT_MINIAPP_URL ?? '';
|
||||
@@ -46,6 +47,59 @@ async function reportAuto(orderId: string, res: AutoAssignResult) {
|
||||
);
|
||||
}
|
||||
|
||||
// ───── рассылки клиентам ─────
|
||||
|
||||
const running = new Set<string>();
|
||||
const pause = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Рассылка: порциями по 25, ~25 сообщений в секунду (лимит Telegram — 30).
|
||||
* Каждый получатель отмечается отдельно, поэтому после перезапуска воркера отправка продолжается с места остановки.
|
||||
*/
|
||||
async function runBroadcast(id: string) {
|
||||
if (running.has(id)) return;
|
||||
running.add(id);
|
||||
try {
|
||||
for (;;) {
|
||||
const b = await db.broadcast.findUnique({ where: { id } });
|
||||
if (!b || b.status !== 'SENDING') return;
|
||||
const batch = await db.broadcastRecipient.findMany({ where: { broadcastId: id, status: 'PENDING' }, take: 25 });
|
||||
if (!batch.length) {
|
||||
await db.broadcast.update({ where: { id }, data: { status: 'DONE', finishedAt: new Date() } });
|
||||
return;
|
||||
}
|
||||
const clients = await db.client.findMany({ where: { id: { in: batch.map((r) => r.clientId) } }, include: { user: true } });
|
||||
const texts = b.texts as Record<string, string>;
|
||||
for (const r of batch) {
|
||||
const c = clients.find((x) => x.id === r.clientId);
|
||||
let status: 'SENT' | 'BLOCKED' | 'FAILED' = 'FAILED';
|
||||
let error: string | null = null;
|
||||
if (c) {
|
||||
const locale = c.user.locale as Locale;
|
||||
const text = (texts[locale] || texts.ru || '').trim();
|
||||
try {
|
||||
const ok = await clientBot.send({
|
||||
chatId: c.user.telegramId,
|
||||
text: escapeHtml(text),
|
||||
buttons: b.appButton ? compactRows([[webAppButton(createT(locale)('bot.client.openApp'), `${CLIENT_APP}/`)]]) : [],
|
||||
});
|
||||
status = ok ? 'SENT' : 'BLOCKED';
|
||||
} catch (e) {
|
||||
error = String(e).slice(0, 300);
|
||||
}
|
||||
}
|
||||
await db.broadcastRecipient.update({ where: { id: r.id }, data: { status, error, sentAt: new Date() } });
|
||||
await db.broadcast.update({ where: { id }, data: status === 'SENT' ? { sent: { increment: 1 } } : status === 'BLOCKED' ? { blocked: { increment: 1 } } : { failed: { increment: 1 } } });
|
||||
await pause(40);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[worker] broadcast failed', id, e);
|
||||
} finally {
|
||||
running.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Новая админка, если уже на сервере; иначе — раздел переписки в старой */
|
||||
const BACKOFFICE_URL = process.env.BACKOFFICE_URL ?? '';
|
||||
const chatButton = (threadId: string | null, peer: string): InlineButton | null =>
|
||||
@@ -286,6 +340,10 @@ async function handle(event: DomainEvent) {
|
||||
for (const a of recipients) await proBot.send({ chatId: a.telegramId!, text, buttons });
|
||||
return;
|
||||
}
|
||||
case 'broadcast.start':
|
||||
// отправка идёт вне задачи очереди — длинная рассылка не держит очередь
|
||||
void runBroadcast(event.broadcastId);
|
||||
return;
|
||||
case 'sales.taskDue': {
|
||||
const task = await db.salesTask.findUnique({ where: { id: event.taskId }, include: { assignee: true, lead: true } });
|
||||
if (!task || task.doneAt || !task.assignee.telegramId || !task.assignee.active) return;
|
||||
@@ -381,6 +439,8 @@ async function tick() {
|
||||
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
|
||||
// задачи продажников, у которых наступил срок
|
||||
await dueSalesTasks(ctx);
|
||||
// рассылки, прерванные перезапуском, — продолжить
|
||||
for (const b of await db.broadcast.findMany({ where: { status: 'SENDING' }, select: { id: true } })) void runBroadcast(b.id);
|
||||
} catch (e) {
|
||||
console.error('[worker] tick failed', e);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BroadcastStatus" AS ENUM ('SENDING', 'DONE', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BroadcastRecipientStatus" AS ENUM ('PENDING', 'SENT', 'BLOCKED', 'FAILED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "PromoCode" ADD COLUMN "categories" "CategoryCode"[] DEFAULT ARRAY[]::"CategoryCode"[],
|
||||
ADD COLUMN "note" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TextOverride" (
|
||||
"key" TEXT NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"updatedById" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TextOverride_pkey" PRIMARY KEY ("key","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Broadcast" (
|
||||
"id" TEXT NOT NULL,
|
||||
"texts" JSONB NOT NULL,
|
||||
"appButton" BOOLEAN NOT NULL DEFAULT true,
|
||||
"filters" JSONB NOT NULL,
|
||||
"status" "BroadcastStatus" NOT NULL DEFAULT 'SENDING',
|
||||
"total" INTEGER NOT NULL DEFAULT 0,
|
||||
"sent" INTEGER NOT NULL DEFAULT 0,
|
||||
"blocked" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finishedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Broadcast_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BroadcastRecipient" (
|
||||
"id" TEXT NOT NULL,
|
||||
"broadcastId" TEXT NOT NULL,
|
||||
"clientId" TEXT NOT NULL,
|
||||
"status" "BroadcastRecipientStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"error" TEXT,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "BroadcastRecipient_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BroadcastRecipient_broadcastId_status_idx" ON "BroadcastRecipient"("broadcastId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BroadcastRecipient_broadcastId_clientId_key" ON "BroadcastRecipient"("broadcastId", "clientId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BroadcastRecipient" ADD CONSTRAINT "BroadcastRecipient_broadcastId_fkey" FOREIGN KEY ("broadcastId") REFERENCES "Broadcast"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -706,6 +706,10 @@ model PromoCode {
|
||||
validFrom DateTime?
|
||||
validUntil DateTime?
|
||||
active Boolean @default(true)
|
||||
/// для команды: откуда код (рассылка, блогер, партнёр)
|
||||
note String?
|
||||
/// на какие направления действует; пусто — на все
|
||||
categories CategoryCode[] @default([])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
uses PromoCodeUse[]
|
||||
@@ -1369,3 +1373,65 @@ model SalesTask {
|
||||
|
||||
@@index([assigneeId, doneAt, dueAt])
|
||||
}
|
||||
|
||||
// ───────────── Продукты, рассылки (этап 5) ─────────────
|
||||
|
||||
/// Текст, изменённый в админке поверх переводов из packages/i18n (название и описание услуги, пункт чек-листа…).
|
||||
/// Пустой — не храним: действует стандартный перевод
|
||||
model TextOverride {
|
||||
key String
|
||||
locale Locale
|
||||
value String
|
||||
updatedById String?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@id([key, locale])
|
||||
}
|
||||
|
||||
enum BroadcastStatus {
|
||||
SENDING
|
||||
DONE
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum BroadcastRecipientStatus {
|
||||
PENDING
|
||||
SENT
|
||||
/// заблокировал бота или ни разу его не открывал
|
||||
BLOCKED
|
||||
FAILED
|
||||
}
|
||||
|
||||
/// Рассылка клиентам в бот по сегменту (фильтры списка клиентов). Текст — на языке клиента, фолбэк — русский
|
||||
model Broadcast {
|
||||
id String @id @default(cuid())
|
||||
/// { ru, en, zh, hy, es }
|
||||
texts Json
|
||||
/// кнопка «Открыть приложение» под сообщением
|
||||
appButton Boolean @default(true)
|
||||
/// фильтры списка клиентов, по которым собрали получателей
|
||||
filters Json
|
||||
status BroadcastStatus @default(SENDING)
|
||||
total Int @default(0)
|
||||
sent Int @default(0)
|
||||
blocked Int @default(0)
|
||||
failed Int @default(0)
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
|
||||
recipients BroadcastRecipient[]
|
||||
}
|
||||
|
||||
model BroadcastRecipient {
|
||||
id String @id @default(cuid())
|
||||
broadcastId String
|
||||
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
|
||||
clientId String
|
||||
status BroadcastRecipientStatus @default(PENDING)
|
||||
error String?
|
||||
sentAt DateTime?
|
||||
|
||||
@@unique([broadcastId, clientId])
|
||||
@@index([broadcastId, status])
|
||||
}
|
||||
@@ -56,12 +56,43 @@ function interpolate(s: string, params?: TParams) {
|
||||
* выбираются по params.count. Отсутствующий ключ возвращается как есть — его ловит `pnpm i18n:check`.
|
||||
*/
|
||||
const merged = new Map<Locale, Messages>();
|
||||
/** Тексты, изменённые в админке (TextOverride): поверх переводов из locales/*.json */
|
||||
const overrides: Partial<Record<Locale, Messages>> = {};
|
||||
let overridesVersion = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function withFallback(locale: Locale): Messages {
|
||||
let m = merged.get(locale);
|
||||
if (!m) merged.set(locale, (m = { ...resources.ru, ...(resources[locale] ?? {}) }));
|
||||
// свой перевод языка важнее русской правки; своя правка языка важнее всего
|
||||
if (!m) merged.set(locale, (m = { ...resources.ru, ...(overrides.ru ?? {}), ...(resources[locale] ?? {}), ...(overrides[locale] ?? {}) }));
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подставить тексты из админки. Вызывают API, боты и воркер (из БД) и Mini App (из public.texts).
|
||||
* После вызова createT отдаёт новые тексты; подписчики (React-провайдер) перерисовываются.
|
||||
*/
|
||||
export function setTextOverrides(rows: { key: string; locale: string; value: string }[]) {
|
||||
for (const l of LOCALES) overrides[l] = {};
|
||||
for (const r of rows) if (isLocale(r.locale) && r.value) overrides[r.locale]![r.key] = r.value;
|
||||
merged.clear();
|
||||
overridesVersion++;
|
||||
for (const cb of listeners) cb();
|
||||
}
|
||||
|
||||
/** Номер версии правок — чтобы мемоизированный t пересоздавался */
|
||||
export const textOverridesVersion = () => overridesVersion;
|
||||
|
||||
export function onTextOverrides(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
|
||||
/** Стандартный перевод (без правок из админки) — подсказка в форме редактирования */
|
||||
export function defaultText(locale: Locale, key: string): string | null {
|
||||
return resources[locale]?.[key] ?? null;
|
||||
}
|
||||
|
||||
export function createT(locale: Locale): TFunction {
|
||||
return tFromMessages(locale, withFallback(locale));
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export type DomainEvent =
|
||||
| { type: 'chat.assigned'; threadId: string; assigneeId: string; byAdminId: string }
|
||||
/** срок задачи продажника — напоминание в рабочий бот */
|
||||
| { type: 'sales.taskDue'; taskId: string }
|
||||
/** рассылка клиентам запущена — воркер отправляет */
|
||||
| { type: 'broadcast.start'; broadcastId: string }
|
||||
| { type: 'lead.submitted'; leadId: string }
|
||||
| { type: 'clientRequest.created'; requestId: string }
|
||||
| { type: 'webRequest.created'; requestId: string };
|
||||
@@ -88,7 +90,11 @@ export type DomainErrorCode =
|
||||
| 'ALREADY_CONVERTED'
|
||||
| 'AGENCY_CODE_FORMAT'
|
||||
| 'AGENCY_CODE_TAKEN'
|
||||
| 'REASON_REQUIRED';
|
||||
| 'REASON_REQUIRED'
|
||||
| 'PROMO_INVALID'
|
||||
| 'PROMO_EXPIRED'
|
||||
| 'PROMO_USED'
|
||||
| 'PROMO_NOT_FOR_SERVICE';
|
||||
|
||||
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
|
||||
export const EVENTS_QUEUE = 'barev-events';
|
||||
@@ -16,4 +16,6 @@ export * from './clientRequests.ts';
|
||||
export * from './concierge.ts';
|
||||
export * from './chats.ts';
|
||||
export * from './sales.ts';
|
||||
export * from './texts.ts';
|
||||
export * from './promo.ts';
|
||||
export * from './partners.ts';
|
||||
@@ -25,14 +25,19 @@ import { getServiceByCode, type ParsedService } from './catalog.ts';
|
||||
import { getMembership } from './clients.ts';
|
||||
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
|
||||
import { getSettings } from './settings.ts';
|
||||
import { findPromo } from './promo.ts';
|
||||
|
||||
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>) {
|
||||
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>, promoCode?: string | null) {
|
||||
const service = await getServiceByCode(ctx.db, serviceCode, { activeOnly: true });
|
||||
const params = validateParams(service.paramsSchema, rawParams);
|
||||
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
|
||||
const membership = await getMembership(ctx.db, clientId, ctx.now());
|
||||
const q = quote(service.pricing, params.values, discountFor(membership.level, service.categoryDiscountPct, service.categoryEliteDiscountPct));
|
||||
return { service, params: params.values, quote: q, membership };
|
||||
const memberPct = discountFor(membership.level, service.categoryDiscountPct, service.categoryEliteDiscountPct);
|
||||
// промокод не складывается со скидкой подписки — берём большую
|
||||
const promo = promoCode?.trim() ? await findPromo(ctx.db, { code: promoCode, clientId, category: service.category, now: ctx.now() }) : null;
|
||||
const promoWins = !!promo && promo.discountPct! > memberPct;
|
||||
const q = quote(service.pricing, params.values, promoWins ? promo!.discountPct! : memberPct);
|
||||
return { service, params: params.values, quote: q, membership, promo: promoWins ? promo : null };
|
||||
}
|
||||
|
||||
export async function availableSlots(ctx: Ctx, input: { serviceCode: string; params: Record<string, unknown>; dateKey: string; excludeOrderId?: string }) {
|
||||
@@ -78,13 +83,15 @@ export interface CreateOrderInput {
|
||||
createdBy?: { type: 'ADMIN'; id: string };
|
||||
/** оператор ставит время, даже если по графику все заняты (договорился с исполнителем сам) */
|
||||
allowBusySlot?: boolean;
|
||||
/** промокод на скидку (применяется, если выгоднее скидки подписки) */
|
||||
promoCode?: string | null;
|
||||
}
|
||||
|
||||
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
|
||||
export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
|
||||
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: input.clientId }, include: { user: true } });
|
||||
if (!client.consentAt) throw new DomainError('CONSENT_REQUIRED');
|
||||
const { service, params, quote: q } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params);
|
||||
const { service, params, quote: q, promo } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params, input.promoCode);
|
||||
if (!service.isOnline && (!input.addressText?.trim() || !input.districtId)) throw new DomainError('ADDRESS_REQUIRED');
|
||||
const flow = getFlow(service.flow);
|
||||
|
||||
@@ -95,7 +102,7 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
|
||||
if (!input.allowBusySlot) await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
|
||||
else if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
|
||||
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
|
||||
return tx.order.create({
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
clientId: input.clientId,
|
||||
serviceId: service.id,
|
||||
@@ -123,6 +130,11 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
|
||||
history: { create: input.createdBy ? { toStatus: flow.initial, actorType: 'ADMIN', actorId: input.createdBy.id, event: 'createdByStaff' } : { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
|
||||
},
|
||||
});
|
||||
if (promo) {
|
||||
await tx.promoCodeUse.create({ data: { promoId: promo.id, clientId: input.clientId, orderId: created.id } });
|
||||
await tx.promoCode.update({ where: { id: promo.id }, data: { usedCount: { increment: 1 } } });
|
||||
}
|
||||
return created;
|
||||
},
|
||||
{ isolationLevel: 'ReadCommitted' },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Промокоды (этап 5): скидка на заказ. Со скидкой подписки не складывается — действует большая.
|
||||
* Один код — один раз на клиента; срок, лимит использований и направления задаются в админке.
|
||||
*/
|
||||
import type { CategoryCode } from '@barev/core';
|
||||
import type { Db, Tx } from '@barev/db';
|
||||
import { DomainError, type Ctx } from './context.ts';
|
||||
|
||||
export const normalizePromo = (code: string) => code.trim().toUpperCase().replace(/\s+/g, '');
|
||||
|
||||
export async function findPromo(db: Db | Tx, input: { code: string; clientId: string; category: CategoryCode; now: Date }) {
|
||||
const promo = await db.promoCode.findUnique({ where: { code: normalizePromo(input.code) } });
|
||||
if (!promo || !promo.active || promo.kind !== 'DISCOUNT' || !promo.discountPct) throw new DomainError('PROMO_INVALID');
|
||||
if ((promo.validFrom && promo.validFrom > input.now) || (promo.validUntil && promo.validUntil < input.now)) throw new DomainError('PROMO_EXPIRED');
|
||||
if (promo.maxUses != null && promo.usedCount >= promo.maxUses) throw new DomainError('PROMO_EXPIRED');
|
||||
if (promo.categories.length && !promo.categories.includes(input.category)) throw new DomainError('PROMO_NOT_FOR_SERVICE');
|
||||
if (await db.promoCodeUse.findUnique({ where: { promoId_clientId: { promoId: promo.id, clientId: input.clientId } } })) throw new DomainError('PROMO_USED');
|
||||
return promo;
|
||||
}
|
||||
|
||||
/** Проверка кода для формы заказа: какую скидку он даст */
|
||||
export async function checkPromo(ctx: Ctx, input: { code: string; clientId: string; category: CategoryCode }) {
|
||||
const p = await findPromo(ctx.db, { ...input, now: ctx.now() });
|
||||
return { code: p.code, discountPct: p.discountPct! };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Тексты из админки (TextOverride) поверх переводов: процессы с доступом к БД (API, боты, воркер)
|
||||
* подгружают их при старте и обновляют раз в минуту.
|
||||
*/
|
||||
import { setTextOverrides } from '@barev/i18n';
|
||||
import type { Db } from '@barev/db';
|
||||
|
||||
/** Ключи, которые можно менять из админки: тексты услуг, доп. услуг, чек-листов и параметров заказа */
|
||||
export const EDITABLE_TEXT_PREFIXES = ['services.', 'extras.', 'checklist.', 'params.'];
|
||||
export const isEditableTextKey = (key: string) => EDITABLE_TEXT_PREFIXES.some((p) => key.startsWith(p)) && key.length <= 120;
|
||||
|
||||
export async function loadTextOverrides(db: Db) {
|
||||
const rows = await db.textOverride.findMany({ select: { key: true, locale: true, value: true } });
|
||||
setTextOverrides(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
export function startTextOverrides(db: Db, everyMs = 60_000) {
|
||||
const run = () => loadTextOverrides(db).catch((e) => console.warn('[texts] не удалось загрузить правки текстов', e));
|
||||
void run();
|
||||
if (!timer) {
|
||||
timer = setInterval(run, everyMs);
|
||||
timer.unref?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createT } from '@barev/i18n';
|
||||
import { createOrder, DomainError, giveConsent, grantSubscription, loadTextOverrides, quoteForClient, startClient } from '../src/index.ts';
|
||||
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
|
||||
|
||||
let fx: Awaited<ReturnType<typeof fixtures>>;
|
||||
beforeEach(async () => {
|
||||
fx = await fixtures();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await db.textOverride.deleteMany({});
|
||||
await loadTextOverrides(db);
|
||||
await db.$disconnect();
|
||||
});
|
||||
|
||||
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
|
||||
const params = { cleaningType: 'regular', rooms: 2 };
|
||||
|
||||
async function client(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
|
||||
const s = await startClient(ctx, tgUser(tg));
|
||||
await giveConsent(ctx, s.clientId);
|
||||
return s.clientId;
|
||||
}
|
||||
|
||||
describe('промокоды', () => {
|
||||
it('скидка по коду; один раз на клиента; не складывается с подпиской — берётся большая', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
await db.promoCode.create({ data: { code: 'AUTUMN20', kind: 'DISCOUNT', discountPct: 20 } });
|
||||
await db.promoCode.create({ data: { code: 'SMALL5', kind: 'DISCOUNT', discountPct: 5 } });
|
||||
const c = await client(ctx, 901);
|
||||
|
||||
const q = await quoteForClient(ctx, c, 'cleaning', params, 'autumn20');
|
||||
expect(q.quote).toMatchObject({ basePrice: 19000, discountPct: 20, totalPrice: 15200 });
|
||||
expect(q.promo?.code).toBe('AUTUMN20');
|
||||
|
||||
const o = await createOrder(ctx, { clientId: c, serviceCode: 'cleaning', params, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, promoCode: 'AUTUMN20' });
|
||||
expect(o.totalPrice).toBe(15200);
|
||||
expect((await db.promoCode.findUniqueOrThrow({ where: { code: 'AUTUMN20' } })).usedCount).toBe(1);
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'AUTUMN20'))).toBe('PROMO_USED');
|
||||
|
||||
// подписчик со скидкой 10%: код на 5% не нужен — остаётся скидка подписки, код не тратится
|
||||
const member = await client(ctx, 902);
|
||||
await grantSubscription(ctx, { clientId: member, plan: 'BAREV_PLUS', days: 30 });
|
||||
const m = await quoteForClient(ctx, member, 'cleaning', params, 'SMALL5');
|
||||
expect(m.quote.discountPct).toBe(10);
|
||||
expect(m.promo).toBeNull();
|
||||
});
|
||||
|
||||
it('выключен, истёк, исчерпан или не для этого направления — понятная ошибка', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const c = await client(ctx, 903);
|
||||
await db.promoCode.create({ data: { code: 'OFF', kind: 'DISCOUNT', discountPct: 10, active: false } });
|
||||
await db.promoCode.create({ data: { code: 'OLD', kind: 'DISCOUNT', discountPct: 10, validUntil: new Date(NOW.getTime() - 1000) } });
|
||||
await db.promoCode.create({ data: { code: 'FULL', kind: 'DISCOUNT', discountPct: 10, maxUses: 1, usedCount: 1 } });
|
||||
await db.promoCode.create({ data: { code: 'DOCSONLY', kind: 'DISCOUNT', discountPct: 10, categories: ['DOCS'] } });
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'NOPE'))).toBe('PROMO_INVALID');
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'OFF'))).toBe('PROMO_INVALID');
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'OLD'))).toBe('PROMO_EXPIRED');
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'FULL'))).toBe('PROMO_EXPIRED');
|
||||
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'DOCSONLY'))).toBe('PROMO_NOT_FOR_SERVICE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('тексты из админки', () => {
|
||||
it('правка подменяет стандартный текст, свой перевод языка важнее русской правки', async () => {
|
||||
const before = createT('ru')('services.cleaning.name');
|
||||
await db.textOverride.create({ data: { key: 'services.cleaning.name', locale: 'ru', value: 'Уборка квартиры' } });
|
||||
await db.textOverride.create({ data: { key: 'services.cleaning.name', locale: 'hy', value: 'Բնակարանի մաքրում' } });
|
||||
await loadTextOverrides(db);
|
||||
expect(createT('ru')('services.cleaning.name')).toBe('Уборка квартиры');
|
||||
expect(createT('hy')('services.cleaning.name')).toBe('Բնակարանի մաքրում');
|
||||
// у английского свой перевод — русская правка его не трогает
|
||||
expect(createT('en')('services.cleaning.name')).not.toBe('Уборка квартиры');
|
||||
await db.textOverride.deleteMany({});
|
||||
await loadTextOverrides(db);
|
||||
expect(createT('ru')('services.cleaning.name')).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { createT, formatAmd, formatDateTime, isLocale, type Locale, type TFunction } from '@barev/i18n';
|
||||
import { createT, formatAmd, formatDateTime, isLocale, onTextOverrides, setTextOverrides, textOverridesVersion, type Locale, type TFunction } from '@barev/i18n';
|
||||
import { reportClientError } from './telegram.ts';
|
||||
|
||||
interface I18nValue {
|
||||
@@ -26,6 +26,12 @@ export function I18nProvider({ initial, children }: { initial: Locale; children:
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
// тексты из админки подгрузились — перерисовать
|
||||
const [version, setVersion] = useState(textOverridesVersion());
|
||||
useEffect(() => {
|
||||
const off = onTextOverrides(() => setVersion(textOverridesVersion()));
|
||||
return () => void off();
|
||||
}, []);
|
||||
const value = useMemo<I18nValue>(
|
||||
() => ({
|
||||
locale,
|
||||
@@ -34,7 +40,8 @@ export function I18nProvider({ initial, children }: { initial: Locale; children:
|
||||
amd: (n) => formatAmd(locale, n),
|
||||
dt: (d, opts) => formatDateTime(locale, d, opts),
|
||||
}),
|
||||
[locale],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[locale, version],
|
||||
);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
@@ -56,3 +63,16 @@ export function errorText(t: TFunction, err: unknown): string {
|
||||
}
|
||||
return t('errors.generic');
|
||||
}
|
||||
|
||||
/** Тексты, изменённые в админке (названия и описания услуг и т.п.) — подгрузить с API при старте Mini App */
|
||||
export async function loadTextOverrides(url = '/trpc/public.texts') {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { result?: { data?: { json?: { key: string; locale: string; value: string }[] } } };
|
||||
const rows = body.result?.data?.json;
|
||||
if (rows) setTextOverrides(rows);
|
||||
} catch {
|
||||
// нет сети — остаются стандартные тексты
|
||||
}
|
||||
}
|
||||
Generated
+3
@@ -236,6 +236,9 @@ importers:
|
||||
'@barev/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db
|
||||
'@barev/i18n':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/i18n
|
||||
'@barev/services':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/services
|
||||
|
||||
Reference in new issue
Block a user