diff --git a/apps/api/package.json b/apps/api/package.json index fb1c5e4..4131ac8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/routers/backoffice/admin5.ts b/apps/api/src/routers/backoffice/admin5.ts new file mode 100644 index 0000000..5e9c7ab --- /dev/null +++ b/apps/api/src/routers/backoffice/admin5.ts @@ -0,0 +1,137 @@ +/** Этап 5: рассылки клиентам, журнал действий, настройки */ +import { DAY, SETTING_KEYS, settingsSchema } from '@barev/core'; +import type { Prisma } from '@barev/db'; +import { getSettings } from '@barev/services'; +import { TelegramSender } from '@barev/services/telegram'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { env } from '../../env.ts'; +import { rolesProcedure, router, superProcedure } from '../../trpc.ts'; +import { clientFilters, filteredClients } from './clients.ts'; + +const LANGS = ['ru', 'en', 'zh', 'hy', 'es'] as const; +const texts = z.object({ ru: z.string().trim().min(1).max(3500), en: z.string().max(3500), zh: z.string().max(3500), hy: z.string().max(3500), es: z.string().max(3500) }); +const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); + +/** Рассылки делают поддержка и суперадмин */ +const bc = rolesProcedure('SUPPORT'); + +/** Получатели: клиенты по фильтрам, только давшие согласие (иначе бот им не писал) */ +async function recipients(db: import('@barev/db').Db, filters: z.infer, 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).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 | 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 } }); + } + }), +}); diff --git a/apps/api/src/routers/backoffice/clients.ts b/apps/api/src/routers/backoffice/clients.ts index 4390700..228bf83 100644 --- a/apps/api/src/routers/backoffice/clients.ts +++ b/apps/api/src/routers/backoffice/clients.ts @@ -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, diff --git a/apps/api/src/routers/backoffice/index.ts b/apps/api/src/routers/backoffice/index.ts index d18f011..104b05b 100644 --- a/apps/api/src/routers/backoffice/index.ts +++ b/apps/api/src/routers/backoffice/index.ts @@ -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, }); diff --git a/apps/api/src/routers/backoffice/orders.ts b/apps/api/src/routers/backoffice/orders.ts index f0cbb35..d89c4c6 100644 --- a/apps/api/src/routers/backoffice/orders.ts +++ b/apps/api/src/routers/backoffice/orders.ts @@ -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, diff --git a/apps/api/src/routers/backoffice/products.ts b/apps/api/src/routers/backoffice/products.ts new file mode 100644 index 0000000..4283f09 --- /dev/null +++ b/apps/api/src/routers/backoffice/products.ts @@ -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> = {}; + 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; + } + 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)), +}); diff --git a/apps/api/src/routers/client.ts b/apps/api/src/routers/client.ts index fa6113b..96d6a51 100644 --- a/apps/api/src/routers/client.ts +++ b/apps/api/src/routers/client.ts @@ -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 }) => { diff --git a/apps/api/src/routers/public.ts b/apps/api/src/routers/public.ts index 4822327..bfb79d4 100644 --- a/apps/api/src/routers/public.ts +++ b/apps/api/src/routers/public.ts @@ -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 { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 45907a1..4ebce3c 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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}`); diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx index c61c3ef..608ba30 100644 --- a/apps/backoffice/src/App.tsx +++ b/apps/backoffice/src/App.tsx @@ -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 = ; else if ((path === '/chats' || threadId) && canChat) page = ; else if (path === '/orders' && canOrders) page = ; @@ -101,6 +107,11 @@ function Shell({ me }: { me: Me }) { else if (agencyId && isSuper) page = ; else if (path === '/venues' && isSuper) page = ; else if (venueId && isSuper) page = ; + else if (path === '/products' && canProducts) page = ; + else if (productId && canProducts) page = ; + else if (path === '/broadcasts' && canBroadcast) page = ; + else if (path === '/journal' && isSuper) page = ; + else if (path === '/settings' && isSuper) page = ; else if (path === '/clients' && current) page = ; else if (clientId && items.some((n) => n.path === '/clients')) page = ; else if (path === '/team' && isSuper) page = ; diff --git a/apps/backoffice/src/admin5.css b/apps/backoffice/src/admin5.css new file mode 100644 index 0000000..6c7494b --- /dev/null +++ b/apps/backoffice/src/admin5.css @@ -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); + } +} diff --git a/apps/backoffice/src/components/kit.tsx b/apps/backoffice/src/components/kit.tsx index d88ef9d..eac3f14 100644 --- a/apps/backoffice/src/components/kit.tsx +++ b/apps/backoffice/src/components/kit.tsx @@ -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; diff --git a/apps/backoffice/src/i18n.tsx b/apps/backoffice/src/i18n.tsx index 96f61d5..ef78015 100644 --- a/apps/backoffice/src/i18n.tsx +++ b/apps/backoffice/src/i18n.tsx @@ -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', diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx index 9feb586..6660fe5 100644 --- a/apps/backoffice/src/main.tsx +++ b/apps/backoffice/src/main.tsx @@ -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'; diff --git a/apps/backoffice/src/pages/Admin5.tsx b/apps/backoffice/src/pages/Admin5.tsx new file mode 100644 index 0000000..8ac2332 --- /dev/null +++ b/apps/backoffice/src/pages/Admin5.tsx @@ -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; 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({ 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 ( + + + + + + + } + > +
+ + {p ? ( + + {t('bc.recipients', { n: p.total })} + + {LANGS.filter((l) => p.byLocale[l] > 0) + .map((l) => `${l.toUpperCase()} ${p.byLocale[l]}`) + .join(' · ') || '—'} + + + ) : ( + {t('ui.loading')} + )} +
+

{t('bc.filtersHint')}

+ +