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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 03:17:56 +04:00
1 parent ac786cc5cc
commit 0edc6432b7
34 files changed
+2704 -26

No files matched your search

+1
View File
@@ -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",
+137
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** Рассылки делают поддержка и суперадмин */
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 } });
}
}),
});
+1 -1
View File
@@ -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,
+6
View File
@@ -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,
});
+5 -3
View File
@@ -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,
+160
View File
@@ -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)),
});
+7 -2
View File
@@ -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 }) => {
+3
View File
@@ -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 -1
View File
@@ -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}`);
+13 -2
View File
@@ -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} />;
+118
View File
@@ -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);
}
}
+1
View File
@@ -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;
+567
View File
@@ -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',
+1
View File
@@ -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';
+405
View File
@@ -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>
);
}
+15 -4
View File
@@ -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)} />}
</>
);
}
+14 -1
View File
@@ -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>
);
+837
View File
@@ -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>
);
}
+2
View File
@@ -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');
+2
View File
@@ -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');
+3 -1
View File
@@ -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(
+3 -1
View File
@@ -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>
+61 -1
View File
@@ -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 {