From 45477fb018adf6b9a8d15da40fec013872e34100 Mon Sep 17 00:00:00 2001 From: zabudko-ru Date: Sun, 25 Oct 2026 22:57:03 +0400 Subject: [PATCH] Bariki cashback, order edit, and current work in progress Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/admin/src/pages/WebRequests.tsx | 6 +- apps/api/package.json | 4 +- apps/api/scripts/dev-partners-demo.ts | 81 ++ apps/api/scripts/dev-points-demo.ts | 46 + apps/api/src/routers/backoffice/index.ts | 2 + apps/api/src/routers/backoffice/orders.ts | 25 +- apps/api/src/routers/backoffice/partners.ts | 126 +- apps/api/src/routers/backoffice/points.ts | 398 ++++++ apps/api/src/routers/backoffice/providers.ts | 11 +- apps/api/src/routers/client.ts | 24 +- apps/api/src/routers/pro.ts | 69 +- apps/api/src/routers/public.ts | 7 +- apps/api/src/server.ts | 25 +- apps/api/src/trpc.ts | 13 +- apps/backoffice/src/App.tsx | 8 + apps/backoffice/src/admin5.css | 37 + apps/backoffice/src/i18n.tsx | 883 ++++++++++++- apps/backoffice/src/orders.css | 33 + apps/backoffice/src/pages/Admin5.tsx | 7 +- apps/backoffice/src/pages/Agencies.tsx | 47 +- apps/backoffice/src/pages/ClientCard.tsx | 2 + apps/backoffice/src/pages/Moves.tsx | 9 +- apps/backoffice/src/pages/NewOrder.tsx | 4 +- apps/backoffice/src/pages/OrderCard.tsx | 179 ++- apps/backoffice/src/pages/Points.tsx | 1089 +++++++++++++++++ apps/backoffice/src/pages/ProviderCard.tsx | 4 +- apps/backoffice/src/pages/ServicePartners.tsx | 485 ++++++++ apps/bot-client/src/index.ts | 156 ++- apps/bot-pro/src/index.ts | 108 +- apps/landing/app/[locale]/clean/page.tsx | 3 +- apps/landing/app/[locale]/pricing/page.tsx | 117 ++ apps/landing/app/[locale]/privacy/page.tsx | 78 +- apps/landing/app/[locale]/refund/page.tsx | 22 + apps/landing/app/[locale]/terms/page.tsx | 22 + apps/landing/app/clean.css | 33 + apps/landing/app/landing.css | 123 ++ apps/landing/components/clean.tsx | 100 +- apps/landing/components/home.tsx | 3 +- apps/landing/components/legal.tsx | 107 ++ apps/landing/lib/seo.ts | 2 +- apps/landing/next-env.d.ts | 4 +- apps/landing/tsconfig.tsbuildinfo | 1 + apps/miniapp-client/src/App.tsx | 2 + apps/miniapp-client/src/app.css | 102 ++ .../src/screens/OrderDetail.tsx | 8 +- apps/miniapp-client/src/screens/OrderForm.tsx | 28 +- apps/miniapp-client/src/screens/Points.tsx | 213 ++++ apps/miniapp-client/src/screens/Profile.tsx | 8 + apps/miniapp-pro/src/App.tsx | 7 +- apps/miniapp-pro/src/app.css | 57 + apps/miniapp-pro/src/auth.ts | 53 +- apps/miniapp-pro/src/main.tsx | 23 +- apps/miniapp-pro/src/screens/Company.tsx | 650 ++++++++-- apps/miniapp-pro/src/screens/History.tsx | 8 +- apps/miniapp-pro/src/screens/Job.tsx | 4 +- apps/miniapp-pro/vite.config.ts | 4 +- apps/worker/src/index.ts | 167 ++- packages/core/src/domain.test.ts | 16 +- packages/core/src/index.ts | 2 + packages/core/src/membership.ts | 14 +- packages/core/src/partners.test.ts | 43 + packages/core/src/partners.ts | 70 ++ packages/core/src/points.test.ts | 45 + packages/core/src/points.ts | 68 + packages/core/src/settings.ts | 32 +- packages/core/src/slots.ts | 8 + .../migration.sql | 63 + .../migration.sql | 26 + .../20261026090000_points/migration.sql | 267 ++++ .../migration.sql | 4 + packages/db/prisma/schema.prisma | 313 ++++- packages/i18n/locales/en.json | 514 +++++++- packages/i18n/locales/es.json | 514 +++++++- packages/i18n/locales/hy.json | 510 +++++++- packages/i18n/locales/ru.json | 516 +++++++- packages/i18n/locales/zh.json | 514 +++++++- packages/services/src/assignment.ts | 79 +- packages/services/src/clients.ts | 26 +- packages/services/src/context.ts | 32 +- packages/services/src/index.ts | 3 + packages/services/src/orderEdit.ts | 134 ++ packages/services/src/orders.ts | 37 +- packages/services/src/payouts.ts | 17 +- packages/services/src/points.ts | 755 ++++++++++++ packages/services/src/providers.ts | 14 +- packages/services/src/queries.ts | 2 + packages/services/src/servicePartners.ts | 470 +++++++ packages/services/src/webRequests.ts | 155 ++- packages/services/test/autoassign.test.ts | 6 +- packages/services/test/flows.test.ts | 10 +- packages/services/test/helpers.ts | 14 + packages/services/test/orderEdit.test.ts | 70 ++ packages/services/test/partners.test.ts | 193 +++ packages/services/test/points.test.ts | 212 ++++ 94 files changed, 10955 insertions(+), 650 deletions(-) create mode 100644 apps/api/scripts/dev-partners-demo.ts create mode 100644 apps/api/scripts/dev-points-demo.ts create mode 100644 apps/api/src/routers/backoffice/points.ts create mode 100644 apps/backoffice/src/pages/Points.tsx create mode 100644 apps/backoffice/src/pages/ServicePartners.tsx create mode 100644 apps/landing/app/[locale]/pricing/page.tsx create mode 100644 apps/landing/app/[locale]/refund/page.tsx create mode 100644 apps/landing/app/[locale]/terms/page.tsx create mode 100644 apps/landing/components/legal.tsx create mode 100644 apps/landing/tsconfig.tsbuildinfo create mode 100644 apps/miniapp-client/src/screens/Points.tsx create mode 100644 packages/core/src/partners.test.ts create mode 100644 packages/core/src/partners.ts create mode 100644 packages/core/src/points.test.ts create mode 100644 packages/core/src/points.ts create mode 100644 packages/db/prisma/migrations/20261025120000_partners_agency_gifts/migration.sql create mode 100644 packages/db/prisma/migrations/20261025130000_pro_web_sessions/migration.sql create mode 100644 packages/db/prisma/migrations/20261026090000_points/migration.sql create mode 100644 packages/db/prisma/migrations/20261026120000_checklist_by_admin/migration.sql create mode 100644 packages/services/src/orderEdit.ts create mode 100644 packages/services/src/points.ts create mode 100644 packages/services/src/servicePartners.ts create mode 100644 packages/services/test/orderEdit.test.ts create mode 100644 packages/services/test/partners.test.ts create mode 100644 packages/services/test/points.test.ts diff --git a/apps/admin/src/pages/WebRequests.tsx b/apps/admin/src/pages/WebRequests.tsx index 06b522a..20cca32 100644 --- a/apps/admin/src/pages/WebRequests.tsx +++ b/apps/admin/src/pages/WebRequests.tsx @@ -72,7 +72,7 @@ export function WebRequests() { {r.number}
{t('admin.webRequests.created', { date: dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) })}
- {dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} + {r.preferredAt ? dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : '—'} {t(`params.cleaning.cleaningType.${p.cleaningType ?? 'regular'}`)}
@@ -83,8 +83,8 @@ export function WebRequests() { )} - {t(`districts.${r.district.code}`)} -
{r.addressText}
+ {r.district ? t(`districts.${r.district.code}`) : '—'} +
{r.addressText ?? '—'}
{r.note &&
{r.note}
} diff --git a/apps/api/package.json b/apps/api/package.json index 4131ac8..3770787 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,7 +16,9 @@ "dev:demo": "tsx scripts/dev-demo.ts", "dev:bo-demo": "tsx scripts/dev-backoffice-demo.ts", "dev:chats-demo": "tsx scripts/dev-chats-demo.ts", - "dev:crm-demo": "tsx scripts/dev-crm-demo.ts" + "dev:crm-demo": "tsx scripts/dev-crm-demo.ts", + "dev:partners-demo": "tsx scripts/dev-partners-demo.ts", + "dev:points-demo": "tsx scripts/dev-points-demo.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1152.0", diff --git a/apps/api/scripts/dev-partners-demo.ts b/apps/api/scripts/dev-partners-demo.ts new file mode 100644 index 0000000..1b6ed41 --- /dev/null +++ b/apps/api/scripts/dev-partners-demo.ts @@ -0,0 +1,81 @@ +/** + * Локальная разработка: демо партнёра услуг (клининг) — заказы у партнёра, сотрудники, доход, + * и одноразовая ссылка на кабинет менеджера в браузере. + * pnpm --filter @barev/api dev:partners-demo + * Клиенты демо — telegramId 100003000+. Повторный запуск пересоздаёт их заказы. + */ +import { fromYerevan, toYerevan } from '@barev/core'; +import { getDb } from '@barev/db'; +import { createOrder, createProWebLogin, dispatcherAssign, giveConsent, markPaid, offerOrder, startClient, transitionOrder, type Ctx } from '@barev/services'; +import { env } from '../src/env.ts'; + +if (env.isProd) throw new Error('Только для разработки'); + +const db = getDb(); +const ctx: Ctx = { db, notify: { emit: async () => {} }, now: () => new Date() }; +const TG_FROM = 100003000n; +const PRO_APP = process.env.PRO_WEB_URL ?? 'http://localhost:5187'; + +// ── чистим прошлое демо ── +const old = await db.user.findMany({ where: { telegramId: { gte: TG_FROM, lt: TG_FROM + 100n } }, include: { client: true } }); +const oldClients = old.map((u) => u.client?.id).filter((x): x is string => !!x); +const oldOrders = (await db.order.findMany({ where: { clientId: { in: oldClients } }, select: { id: true } })).map((o) => o.id); +await db.partnerEarning.deleteMany({ where: { orderId: { in: oldOrders } } }); +await db.agencyCommission.deleteMany({ where: { orderId: { in: oldOrders } } }); +await db.order.deleteMany({ where: { id: { in: oldOrders } } }); + +let company = await db.providerCompany.findFirst({ where: { active: true, roles: { has: 'CLEANER' } } }); +if (!company) throw new Error('Нет компании-партнёра — запустите seed'); +company = await db.providerCompany.update({ where: { id: company.id }, data: { deal: 'PROFIT_SHARE', profitSharePct: 40, fotPct: null, taxPct: null } }); +const manager = await db.companyDispatcher.findFirstOrThrow({ where: { companyId: company.id } }); +const staff = await db.provider.findMany({ where: { companyId: company.id, active: true } }); +// график всем сотрудникам: каждый день 9–20, лимит 3 заказа в день у первого +for (const [i, p] of staff.entries()) { + await db.providerSchedule.deleteMany({ where: { providerId: p.id } }); + await db.providerSchedule.createMany({ data: [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ providerId: p.id, weekday, startMinute: 9 * 60, endMinute: 20 * 60 })) }); + await db.provider.update({ where: { id: p.id }, data: { maxOrdersPerDay: i === 0 ? 3 : null, maxHoursPerDay: i === 0 ? 8 : null } }); +} +const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } }); +const admin = await db.adminUser.findFirstOrThrow({ where: { email: 'owner@barev.local' } }); + +const at = (dayOffset: number, hour: number) => { + const p = toYerevan(new Date(Date.now() + dayOffset * 24 * 3600_000)); + return fromYerevan(p.year, p.month, p.day, hour * 60); +}; +const NAMES = ['Ольга', 'James', 'Ирина', 'Arpi', 'Лилит']; +async function demoOrder(i: number, when: Date, rooms: number) { + const s = await startClient(ctx, { id: Number(TG_FROM) + i, first_name: NAMES[i % NAMES.length]!, language_code: i % 2 ? 'en' : 'ru' }); + await giveConsent(ctx, s.clientId); + return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms, bathrooms: 1 }, scheduledAt: when, addressText: `Абовяна ${10 + i}`, districtId: kentron.id, allowBusySlot: true }); +} + +// 1) два заказа ждут назначения у партнёра +for (const [i, d] of [ + [0, 2], + [1, 3], +] as const) { + const o = await demoOrder(i, at(d, 11), 2 + i); + await offerOrder(ctx, { orderId: o.id, adminId: admin.id, target: { kind: 'company', id: company.id } }); +} +// 2) один назначен на сотрудника +const assigned = await demoOrder(2, at(2, 15), 1); +await offerOrder(ctx, { orderId: assigned.id, adminId: admin.id, target: { kind: 'company', id: company.id } }); +const linked = staff.find((p) => p.userId) ?? staff[0]!; +await dispatcherAssign(ctx, { orderId: assigned.id, companyId: company.id, providerId: linked.id, dispatcherUserId: manager.userId }); +// 3) один выполнен и оплачен — доход партнёра +const done = await demoOrder(3, at(1, 10), 3); +await offerOrder(ctx, { orderId: done.id, adminId: admin.id, target: { kind: 'company', id: company.id } }); +await dispatcherAssign(ctx, { orderId: done.id, companyId: company.id, providerId: linked.id, dispatcherUserId: manager.userId }); +for (const to of ['EN_ROUTE', 'CLEANING'] as const) await transitionOrder(ctx, { orderId: done.id, to, actor: 'ADMIN', actorId: admin.id, event: 'demo' }); +await db.orderChecklistItem.updateMany({ where: { orderId: done.id }, data: { checkedAt: new Date() } }); +await db.order.update({ where: { id: done.id }, data: { status: 'DONE', completedAt: new Date() } }); +await markPaid(ctx, { orderId: done.id, by: { type: 'ADMIN', id: admin.id } }); + +// 4) новый заказ без исполнителя — оператор выбирает, кому отдать (видно, что выгоднее) +const fresh = await demoOrder(4, at(4, 12), 2); +console.log(`Заказ для назначения: #${fresh.number} (${fresh.id})`); + +const code = await createProWebLogin(ctx, manager.userId); +console.log(`Партнёр: ${company.name}`); +console.log(`Кабинет менеджера в браузере (ссылка одноразовая, 15 минут):\n${PRO_APP}/?login=${code}#/company`); +process.exit(0); diff --git a/apps/api/scripts/dev-points-demo.ts b/apps/api/scripts/dev-points-demo.ts new file mode 100644 index 0000000..acaf2c3 --- /dev/null +++ b/apps/api/scripts/dev-points-demo.ts @@ -0,0 +1,46 @@ +/** + * Локальная разработка: демо бариков у клиента «Анна» (telegramId 100000001): + * начисления (доступные и в ожидании), бонус, списание, открытый запрос и предложение системы. + * pnpm --filter @barev/api dev:points-demo + */ +import { DAY } from '@barev/core'; +import { getDb } from '@barev/db'; +import { accruePoints, redeemPoints, requestRedemption, type Ctx } from '@barev/services'; +import { env } from '../src/env.ts'; + +if (env.isProd) throw new Error('Только для разработки'); +const db = getDb(); +const ctx: Ctx = { db, notify: { emit: async () => {} }, now: () => new Date() }; +const OWNER = { adminId: null, owner: true }; + +const user = await db.user.findUniqueOrThrow({ where: { telegramId: 100000001n }, include: { client: true } }); +const clientId = user.client!.id; +// чистим прошлое демо этого клиента +await db.pointsRedemptionItem.deleteMany({ where: { redemption: { clientId } } }); +await db.pointsRedemption.deleteMany({ where: { clientId } }); +await db.pointsReversal.deleteMany({ where: { clientId } }); +await db.pointsSuggestion.deleteMany({ where: { clientId } }); +await db.pointsAccrual.deleteMany({ where: { clientId } }); +await db.client.update({ where: { id: clientId }, data: { pointsActive: 0, pointsPending: 0, pointsDebt: 0, streakCount: 0, streakLastAt: null, pointsActivityAt: null } }); + +const cat = (code: string) => db.pointsCategory.findUniqueOrThrow({ where: { code } }); +const now = Date.now(); +const ago = (d: number) => new Date(now - d * DAY); +const cleaning = await cat('cleaning'); +for (const [i, d] of [50, 25, 3].entries()) { + await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: cleaning.id, orderAmount: 18_000 + i * 2000, serviceDate: ago(d), orderRef: `DEMO-C${i}`, channel: 'WEB' }, OWNER); +} +await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: (await cat('documents')).id, orderAmount: 45_000, excludedAmount: 15_000, serviceDate: ago(10), orderRef: 'DEMO-D1', channel: 'WEB' }, OWNER); +// ожидание 48 ч от даты услуги: старые уже доступны — активируем +const { activatePendingPoints } = await import('@barev/services'); +await activatePendingPoints(ctx); +// свежая уборка — ещё в ожидании +await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: cleaning.id, orderAmount: 19_000, serviceDate: new Date(), orderRef: 'DEMO-C3', channel: 'AUTO' }, OWNER); +const bonus = await db.pointsBonusType.findUniqueOrThrow({ where: { code: 'housewarming' } }); +await accruePoints(ctx, { clientId, kind: 'BONUS', bonusTypeId: bonus.id, amount: 5000, serviceDate: new Date(), channel: 'WEB', commentForClient: 'Yerevan Home' }, OWNER); +await redeemPoints(ctx, { clientId, amount: 2000, orderAmount: 20_000, orderRef: 'DEMO-R1', reason: 'оплата по звонку', channel: 'WEB' }, OWNER); +await requestRedemption(ctx, { clientId, amount: 1500, note: 'Уборка 30 октября, 18 000 ֏' }); +await db.pointsSuggestion.create({ data: { clientId, mechanic: 'first_month_route', dedupeKey: `route:${clientId}`, payload: { categories: ['cleaning', 'documents', 'learning'] }, suggestedAmount: 3000 } }); +const c = await db.client.findUniqueOrThrow({ where: { id: clientId } }); +console.log(`Анна: доступно ${c.pointsActive}, в ожидании ${c.pointsPending}, серия ${c.streakCount}`); +process.exit(0); diff --git a/apps/api/src/routers/backoffice/index.ts b/apps/api/src/routers/backoffice/index.ts index c9fa6a5..3331fec 100644 --- a/apps/api/src/routers/backoffice/index.ts +++ b/apps/api/src/routers/backoffice/index.ts @@ -7,6 +7,7 @@ import { boOrdersRouter } from './orders.ts'; import { boProvidersRouter } from './providers.ts'; import { boCrmRouter } from './crm.ts'; import { boPartnersRouter } from './partners.ts'; +import { boPointsRouter } from './points.ts'; import { boProductsRouter } from './products.ts'; import { boMovesRouter, boRequestsRouter } from './moves.ts'; import { boBroadcastsRouter, boJournalRouter, boSettingsRouter } from './admin5.ts'; @@ -25,6 +26,7 @@ export const backofficeRouter = router({ providers: boProvidersRouter, crm: boCrmRouter, partners: boPartnersRouter, + points: boPointsRouter, products: boProductsRouter, broadcasts: boBroadcastsRouter, journal: boJournalRouter, diff --git a/apps/api/src/routers/backoffice/orders.ts b/apps/api/src/routers/backoffice/orders.ts index d89c4c6..e1a7f40 100644 --- a/apps/api/src/routers/backoffice/orders.ts +++ b/apps/api/src/routers/backoffice/orders.ts @@ -9,6 +9,9 @@ import { getServiceByCode, getSettings, markPaid, + previewOrderEdit, + editOrderComposition, + staffChecklistItem, offerOrder, orderPayout, quoteForClient, @@ -210,7 +213,7 @@ export const boOrdersRouter = router({ const pending = o.offers.find((x) => x.status === 'OFFERED'); const adminIds = [...new Set(o.offers.map((x) => x.createdById).filter((x): x is string => !!x))]; const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: adminIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name])); - const payout = ctx.isSuper && o.provider ? orderPayout(o, o.provider) : null; + const payout = ctx.isSuper && o.provider ? orderPayout(o, o.provider, { fotPct: (await getSettings(ctx.db))['partner.fotPct'] }) : null; const sub = o.client.subscriptions.find((s) => s.startsAt <= now && s.endsAt > now); return { id: o.id, @@ -246,7 +249,7 @@ export const boOrdersRouter = router({ respondedAt: x.respondedAt, })), history: await historyView(ctx.db, o), - checklist: o.checklist.map((c) => ({ code: c.code, done: !!c.checkedAt, requiresPhoto: c.requiresPhoto, fileId: c.fileId })), + checklist: o.checklist.map((c) => ({ code: c.code, done: !!c.checkedAt, requiresPhoto: c.requiresPhoto, fileId: c.fileId, waived: c.photoWaivedReason, byStaff: !!c.checkedByAdminId })), photos: o.files.filter((f) => f.kind === 'PHOTO_REPORT').map((f) => f.id), rating: o.rating ? { score: o.rating.score, comment: o.rating.comment } : null, // деньги — только суперадмину; статус оплаты видят все (поддержка отмечает оплату наличными) @@ -267,7 +270,7 @@ export const boOrdersRouter = router({ /** Подсказка: свободные кандидаты по рейтингу подбора и занятые */ candidates: ordersProcedure.input(idInput).query(async ({ ctx, input }) => { - const { ranked, busy } = await assignmentCandidates(ctx.svc, input.id); + const { ranked, busy, bestId } = await assignmentCandidates(ctx.svc, input.id); const tried = new Set((await ctx.db.orderOffer.findMany({ where: { orderId: input.id }, select: { providerId: true, companyId: true, status: true } })).filter((o) => o.status !== 'OFFERED').map((o) => o.providerId ?? o.companyId)); const view = (c: (typeof ranked)[number] | (typeof busy)[number]) => ({ kind: c.kind, @@ -279,6 +282,12 @@ export const boOrdersRouter = router({ onShift: c.onShift, reasons: 'reasons' in c ? c.reasons : [], tried: tried.has(c.id), + /** самый выгодный для нас из доступных — подсвечивается всем; суммы видит только суперадмин */ + best: `${c.kind}:${c.id}` === bestId, + cost: ctx.isSuper ? c.cost : null, + margin: ctx.isSuper ? c.margin : null, + partnerFree: c.partnerFree ?? null, + partnerStaff: c.partnerStaff ?? null, }); return { free: ranked.map(view), busy: busy.map(view) }; }), @@ -320,6 +329,16 @@ export const boOrdersRouter = router({ // ───── новый заказ за клиента ───── /** Услуги, которые можно заказать сейчас, с формой параметров */ + /** Изменить состав заказа: предпросмотр и сохранение (пересчёт цены или своя цена с причиной) */ + editPreview: ordersProcedure.input(z.object({ id: z.string(), params: z.record(z.string(), z.unknown()) })).query(({ ctx, input }) => previewOrderEdit(ctx.svc, { orderId: input.id, params: input.params })), + edit: ordersProcedure + .input(z.object({ id: z.string(), params: z.record(z.string(), z.unknown()), totalPrice: z.number().int().min(0).nullable().optional(), reason: z.string().trim().max(500).nullable().optional(), notify: z.boolean().default(false) })) + .mutation(({ ctx, input }) => editOrderComposition(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, params: input.params, totalPrice: input.totalPrice, reason: input.reason, notify: input.notify })), + /** Чек-лист за исполнителя */ + checklist: ordersProcedure + .input(z.object({ id: z.string(), code: z.string().max(60), checked: z.boolean(), reason: z.string().trim().max(500).nullable().optional() })) + .mutation(({ ctx, input }) => staffChecklistItem(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, code: input.code, checked: input.checked, reason: input.reason })), + services: ordersProcedure.query(async ({ ctx }) => { const list = await ctx.db.service.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' }, select: { code: true } }); return Promise.all( diff --git a/apps/api/src/routers/backoffice/partners.ts b/apps/api/src/routers/backoffice/partners.ts index 5d81f79..3c8db3d 100644 --- a/apps/api/src/routers/backoffice/partners.ts +++ b/apps/api/src/routers/backoffice/partners.ts @@ -1,5 +1,5 @@ import { DAY, normalizeAgencyCode } from '@barev/core'; -import { agencyLink, createOwnerInvite, savePartnerOffer } from '@barev/services'; +import { agencyLink, createOwnerInvite, getSettings, managerInviteCode, partnerMoney, savePartnerOffer, termsOf } from '@barev/services'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { router, superProcedure } from '../../trpc.ts'; @@ -39,6 +39,8 @@ export const boPartnersRouter = router({ code: a.code, name: a.name, commissionPct: a.commissionPct, + giftPlan: a.giftPlan, + giftMonths: a.giftMonths, active: a.active, sales: a.sales, clients: a._count.clients, @@ -72,6 +74,10 @@ export const boPartnersRouter = router({ code: a.code, name: a.name, commissionPct: a.commissionPct, + giftPlan: a.giftPlan, + giftMonths: a.giftMonths, + housewarmingPoints: a.housewarmingPoints, + housewarmingDays: a.housewarmingDays, contact: a.contact, note: a.note, active: a.active, @@ -92,7 +98,7 @@ export const boPartnersRouter = router({ }), saveAgency: p - .input(z.object({ id: z.string().optional(), code: z.string().max(32), name: z.string().trim().min(1).max(100), commissionPct: z.number().int().min(0).max(100), contact: z.string().max(200).nullable(), note: z.string().max(2000).nullable(), active: z.boolean(), salesId: z.string().nullable() })) + .input(z.object({ id: z.string().optional(), code: z.string().max(32), name: z.string().trim().min(1).max(100), commissionPct: z.number().int().min(0).max(100), giftPlan: z.enum(['BAREV_PLUS', 'ELITE']).nullable(), giftMonths: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(6), z.literal(12)]), housewarmingPoints: z.number().int().min(0).max(1_000_000).default(0), housewarmingDays: z.number().int().min(1).max(1000).default(60), contact: z.string().max(200).nullable(), note: z.string().max(2000).nullable(), active: z.boolean(), salesId: z.string().nullable() })) .mutation(async ({ ctx, input }) => { const code = normalizeAgencyCode(input.code); if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' }); @@ -110,6 +116,122 @@ export const boPartnersRouter = router({ await ctx.db.payout.create({ data: { agencyId: input.id, amount: input.amount, status: 'PAID', paidAt: now, note: input.note, createdById: ctx.admin.id, periodStart: last?.periodEnd ?? monthStart(now), periodEnd: now } }); }), + // ───── партнёры услуг (клининговые компании) ───── + + servicePartners: p.query(async ({ ctx }) => { + const settings = await getSettings(ctx.db); + const list = await ctx.db.providerCompany.findMany({ + orderBy: [{ active: 'desc' }, { name: 'asc' }], + include: { _count: { select: { employees: { where: { active: true } }, dispatchers: true } } }, + }); + const ids = list.map((c) => c.id); + const now = ctx.svc.now(); + const [active, month, accrued, paid] = await Promise.all([ + ctx.db.order.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED'] } }, _count: true }), + ctx.db.partnerEarning.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, period: monthKey(now) }, _sum: { amount: true } }), + ctx.db.partnerEarning.groupBy({ by: ['companyId'], where: { companyId: { in: ids } }, _sum: { amount: true } }), + ctx.db.payout.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, status: 'PAID' }, _sum: { amount: true } }), + ]); + const m = (rows: T[], f: (r: T) => number) => new Map(rows.map((r) => [r.companyId, f(r)])); + const act = m(active, (r) => r._count); + const mon = m(month, (r) => r._sum.amount ?? 0); + const acc = m(accrued, (r) => r._sum.amount ?? 0); + const pd = m(paid, (r) => r._sum.amount ?? 0); + return list.map((c) => ({ + id: c.id, + name: c.name, + active: c.active, + deal: c.deal, + terms: termsOf(c, settings), + autoAssign: c.autoAssign, + staff: c._count.employees, + managers: c._count.dispatchers, + activeOrders: act.get(c.id) ?? 0, + thisMonth: mon.get(c.id) ?? 0, + balance: (acc.get(c.id) ?? 0) - (pd.get(c.id) ?? 0), + })); + }), + + servicePartner: p.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { + const c = await ctx.db.providerCompany.findUnique({ + where: { id: input.id }, + include: { employees: { orderBy: [{ active: 'desc' }, { name: 'asc' }] }, dispatchers: { include: { user: true }, orderBy: { createdAt: 'asc' } } }, + }); + if (!c) throw new TRPCError({ code: 'NOT_FOUND' }); + const settings = await getSettings(ctx.db); + const [money, activeOrders] = await Promise.all([ + partnerMoney(ctx.db, c.id), + ctx.db.order.findMany({ + where: { companyId: c.id, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED'] } }, + orderBy: { scheduledAt: 'asc' }, + take: 30, + include: { service: { select: { code: true } }, provider: { select: { name: true } } }, + }), + ]); + return { + id: c.id, + name: c.name, + contactPhone: c.contactPhone, + note: c.note, + active: c.active, + deal: c.deal, + commissionPct: c.commissionPct, + profitSharePct: c.profitSharePct, + fotPct: c.fotPct, + taxPct: c.taxPct, + terms: termsOf(c, settings), + defaults: { fotPct: settings['partner.fotPct'], taxPct: settings['partner.taxPct'] }, + autoAssign: c.autoAssign, + travelGapMinutes: c.travelGapMinutes, + createdAt: c.createdAt, + managerLink: proBotUrl(c.inviteCode), + managers: c.dispatchers.map((d) => ({ id: d.id, name: name(d.user), since: d.createdAt })), + staff: c.employees.map((e) => ({ id: e.id, name: e.name, phone: e.phone, active: e.active, linked: !!e.userId, maxOrdersPerDay: e.maxOrdersPerDay, maxHoursPerDay: e.maxHoursPerDay })), + orders: activeOrders.map((o) => ({ id: o.id, number: o.number, service: o.service.code, scheduledAt: o.scheduledAt, status: o.status, staff: o.provider?.name ?? null })), + money, + }; + }), + + saveServicePartner: p + .input( + z.object({ + id: z.string().optional(), + name: z.string().trim().min(1).max(100), + contactPhone: z.string().trim().max(30).nullable(), + note: z.string().max(2000).nullable(), + active: z.boolean(), + deal: z.enum(['PROFIT_SHARE', 'COMMISSION']), + commissionPct: z.number().int().min(0).max(100), + profitSharePct: z.number().int().min(0).max(100), + fotPct: z.number().int().min(0).max(100).nullable(), + taxPct: z.number().int().min(0).max(100).nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { id, ...data } = input; + // пока партнёры услуг — только клининг + const c = id + ? await ctx.db.providerCompany.update({ where: { id }, data }) + : await ctx.db.providerCompany.create({ data: { ...data, roles: ['CLEANER'], inviteCode: managerInviteCode() } }); + return { id: c.id }; + }), + + /** Новая ссылка для менеджеров — старая перестаёт работать (уже подключённые остаются) */ + resetManagerLink: p.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { + const c = await ctx.db.providerCompany.update({ where: { id: input.id }, data: { inviteCode: managerInviteCode() } }); + return { link: proBotUrl(c.inviteCode) }; + }), + + removeManager: p.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { + await ctx.db.companyDispatcher.delete({ where: { id: input.id } }); + }), + + servicePartnerPayout: p.input(z.object({ id: z.string(), amount: z.number().int().min(1), note: z.string().max(300).optional() })).mutation(async ({ ctx, input }) => { + const now = ctx.svc.now(); + const last = await ctx.db.payout.findFirst({ where: { companyId: input.id }, orderBy: { periodEnd: 'desc' } }); + await ctx.db.payout.create({ data: { companyId: input.id, amount: input.amount, status: 'PAID', paidAt: now, note: input.note, createdById: ctx.admin.id, periodStart: last?.periodEnd ?? monthStart(now), periodEnd: now } }); + }), + // ───── заведения ───── venues: p.query(async ({ ctx }) => { diff --git a/apps/api/src/routers/backoffice/points.ts b/apps/api/src/routers/backoffice/points.ts new file mode 100644 index 0000000..519465a --- /dev/null +++ b/apps/api/src/routers/backoffice/points.ts @@ -0,0 +1,398 @@ +import { DAY, membershipLevel } from '@barev/core'; +import type { Db } from '@barev/db'; +import { + acceptSuggestion, + accruePoints, + confirmRedemption, + getSettings, + pointsHistory, + previewAccrual, + redeemPoints, + rejectRedemption, + rejectSuggestion, + reverseAccrual, + reversePreview, + reverseRedemption, + type PointsActor, +} from '@barev/services'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { rolesProcedure, router, superProcedure } from '../../trpc.ts'; + +/** + * Барики в новой админке. Управляет суперадмин; поддержка — только если включено в настройках, + * с лимитами на операцию и на сутки (выше — подтверждение суперадмина). + */ +const pointsProcedure = rolesProcedure('SUPPORT').use(async ({ ctx, next }) => { + if (!ctx.isSuper && !(await getSettings(ctx.db))['points.supportCanManage']) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' }); + return next({ ctx: { actor: { adminId: ctx.admin.id, owner: ctx.isSuper } satisfies PointsActor } }); +}); + +const name = (u: { firstName: string | null; lastName: string | null; username: string | null }) => [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—'); +const L5 = z.object({ ru: z.string().max(100), en: z.string().max(100), zh: z.string().max(100), hy: z.string().max(100), es: z.string().max(100) }); +const idem = z.string().min(8).max(80).optional(); + +/** Клиент по телефону, @username, имени или ID */ +async function findClients(db: Db, q: string) { + const s = q.trim().replace(/^@/, ''); + const digits = s.replace(/[^\d]/g, ''); + return db.client.findMany({ + where: { + OR: [ + { id: s }, + { cardCode: s.toUpperCase() }, + { user: { username: { equals: s, mode: 'insensitive' } } }, + { user: { firstName: { contains: s, mode: 'insensitive' } } }, + { user: { lastName: { contains: s, mode: 'insensitive' } } }, + ...(digits.length >= 5 ? [{ user: { phone: { contains: digits } } }] : []), + ], + }, + include: { user: true, subscriptions: { where: { status: 'ACTIVE' } } }, + take: 20, + orderBy: { createdAt: 'desc' }, + }); +} + +export const boPointsRouter = router({ + /** Справочники формы: категории, типы бонусов, правила */ + dictionaries: pointsProcedure.query(async ({ ctx }) => { + const [categories, bonusTypes, s] = await Promise.all([ + ctx.db.pointsCategory.findMany({ orderBy: { sortOrder: 'asc' } }), + ctx.db.pointsBonusType.findMany({ orderBy: { sortOrder: 'asc' } }), + getSettings(ctx.db), + ]); + return { + categories: categories.map((c) => ({ ...c, name: c.name as Record })), + bonusTypes: bonusTypes.map((b) => ({ ...b, name: b.name as Record })), + rules: { pendingHours: s['points.pendingHours'], sharePct: s['points.maxSharePct'], min: s['points.minRedeem'], burnDays: s['points.burnDays'], diffPct: s['points.diffHighlightPct'] }, + owner: ctx.isSuper, + }; + }), + + search: pointsProcedure.input(z.object({ q: z.string().trim().min(1).max(100) })).query(async ({ ctx, input }) => { + const now = ctx.svc.now(); + return (await findClients(ctx.db, input.q)).map((c) => ({ + id: c.id, + name: name(c.user), + username: c.user.username, + phone: c.user.phone, + tier: membershipLevel(c.subscriptions, now), + balance: c.pointsActive, + pending: c.pointsPending, + })); + }), + + /** Карточка клиента: баланс, ожидание, сгорание, источник, флаг проверки, последние операции */ + client: pointsProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { + const s = await getSettings(ctx.db); + const now = ctx.svc.now(); + const c = await ctx.db.client.findUnique({ where: { id: input.id }, include: { user: true, agency: { select: { name: true } }, subscriptions: { where: { status: 'ACTIVE' } } } }); + if (!c) throw new TRPCError({ code: 'NOT_FOUND' }); + const [history, reversed30, pendingNext] = await Promise.all([ + pointsHistory(ctx.db, c.id, { take: 10 }), + ctx.db.pointsReversal.count({ where: { clientId: c.id, targetType: 'ACCRUAL', createdAt: { gte: new Date(now.getTime() - 30 * DAY) } } }), + ctx.db.pointsAccrual.findFirst({ where: { clientId: c.id, status: 'PENDING' }, orderBy: { availableAt: 'asc' } }), + ]); + return { + id: c.id, + name: name(c.user), + phone: c.user.phone, + username: c.user.username, + tier: membershipLevel(c.subscriptions, now), + agency: c.agency?.name ?? null, + balance: c.pointsActive, + pending: c.pointsPending, + pendingNextAt: pendingNext?.availableAt ?? null, + debt: c.pointsDebt, + burnAt: c.pointsActive > 0 && c.pointsActivityAt ? new Date(c.pointsActivityAt.getTime() + s['points.burnDays'] * DAY) : null, + streak: c.streakCount, + flagged: reversed30 > 2, + history, + }; + }), + + preview: pointsProcedure + .input(z.object({ clientId: z.string(), categoryId: z.string(), orderAmount: z.number().int().min(0), excludedAmount: z.number().int().min(0).default(0), paidWithPoints: z.number().int().min(0).default(0), serviceDate: z.coerce.date() })) + .query(async ({ ctx, input }) => previewAccrual(ctx.db, input, await getSettings(ctx.db), ctx.svc.now())), + + /** Начисление по заказу или бонус */ + accrue: pointsProcedure + .input( + z.object({ + clientId: z.string(), + kind: z.enum(['ORDER', 'BONUS']), + categoryId: z.string().optional(), + bonusTypeId: z.string().optional(), + orderRef: z.string().trim().max(60).nullable().optional(), + orderAmount: z.number().int().min(0).optional(), + excludedAmount: z.number().int().min(0).optional(), + paidWithPoints: z.number().int().min(0).optional(), + serviceDate: z.coerce.date(), + amount: z.number().int().min(1).max(10_000_000).optional(), + reason: z.string().trim().max(500).nullable().optional(), + commentForClient: z.string().trim().max(300).nullable().optional(), + noWait: z.boolean().optional(), + ttlDays: z.number().int().min(1).max(1000).nullable().optional(), + idempotencyKey: idem, + }), + ) + .mutation(({ ctx, input }) => accruePoints(ctx.svc, { ...input, channel: 'WEB' }, ctx.actor)), + + /** Списание командой: по заказу вне системы или по звонку (с причиной) */ + redeem: pointsProcedure + .input(z.object({ clientId: z.string(), amount: z.number().int().min(1), orderRef: z.string().trim().max(60).nullable().optional(), orderAmount: z.number().int().min(1).nullable().optional(), reason: z.string().trim().min(1).max(500), idempotencyKey: idem })) + .mutation(({ ctx, input }) => redeemPoints(ctx.svc, { ...input, channel: 'WEB' }, ctx.actor)), + + /** Запросы клиентов «Оплатить бариками» */ + requests: pointsProcedure.query(async ({ ctx }) => { + const list = await ctx.db.pointsRedemption.findMany({ where: { status: 'REQUESTED' }, orderBy: { requestedAt: 'asc' }, include: { client: { include: { user: true } }, order: { select: { id: true, number: true, totalPrice: true, pointsUsed: true } } } }); + return list.map((r) => ({ id: r.id, clientId: r.clientId, name: name(r.client.user), balance: r.client.pointsActive, amount: r.amount, note: r.note, order: r.order, requestedAt: r.requestedAt, expiresAt: r.expiresAt })); + }), + confirm: pointsProcedure + .input(z.object({ id: z.string(), orderAmount: z.number().int().min(1), amount: z.number().int().min(1).optional() })) + .mutation(({ ctx, input }) => confirmRedemption(ctx.svc, { redemptionId: input.id, orderAmount: input.orderAmount, amount: input.amount }, ctx.actor)), + reject: pointsProcedure.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500) })).mutation(({ ctx, input }) => rejectRedemption(ctx.svc, { redemptionId: input.id, reason: input.reason }, ctx.actor)), + + reversePreview: pointsProcedure.input(z.object({ type: z.enum(['ACCRUAL', 'REDEMPTION']), id: z.string() })).query(({ ctx, input }) => reversePreview(ctx.db, input)), + reverse: pointsProcedure + .input(z.object({ type: z.enum(['ACCRUAL', 'REDEMPTION']), id: z.string(), reason: z.string().trim().min(1).max(500) })) + .mutation(async ({ ctx, input }) => { + if (input.type === 'ACCRUAL') await reverseAccrual(ctx.svc, { accrualId: input.id, reason: input.reason, channel: 'WEB' }, ctx.actor); + else await reverseRedemption(ctx.svc, { redemptionId: input.id, reason: input.reason, channel: 'WEB' }, ctx.actor); + }), + + /** Журнал: все операции с автором и каналом; фильтры; выгрузка — на клиенте */ + ledger: pointsProcedure + .input( + z.object({ + clientId: z.string().optional(), + adminId: z.string().optional(), + type: z.enum(['accrual', 'bonus', 'redemption', 'reversal']).optional(), + categoryId: z.string().optional(), + channel: z.enum(['WEB', 'BOT', 'CSV', 'AUTO', 'CLIENT']).optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + take: z.number().int().min(1).max(2000).default(200), + }), + ) + .query(async ({ ctx, input }) => { + const s = await getSettings(ctx.db); + const at = input.from || input.to ? { gte: input.from, lt: input.to } : undefined; + const want = (t: string) => !input.type || input.type === t; + const [acc, red, rev, admins] = await Promise.all([ + want('accrual') || want('bonus') + ? ctx.db.pointsAccrual.findMany({ + where: { clientId: input.clientId, createdById: input.adminId, categoryId: input.categoryId, channel: input.channel, createdAt: at, ...(input.type === 'accrual' ? { kind: 'ORDER' } : input.type === 'bonus' ? { kind: 'BONUS' } : {}) }, + include: { client: { include: { user: true } }, category: true, bonusType: true }, + orderBy: { createdAt: 'desc' }, + take: input.take, + }) + : [], + want('redemption') && !input.categoryId + ? ctx.db.pointsRedemption.findMany({ where: { clientId: input.clientId, confirmedById: input.adminId, channel: input.channel, status: { in: ['CONFIRMED', 'REVERSED', 'REJECTED'] }, requestedAt: at }, include: { client: { include: { user: true } }, order: { select: { number: true } } }, orderBy: { requestedAt: 'desc' }, take: input.take }) + : [], + want('reversal') && !input.categoryId ? ctx.db.pointsReversal.findMany({ where: { clientId: input.clientId, createdById: input.adminId, channel: input.channel, createdAt: at }, orderBy: { createdAt: 'desc' }, take: input.take }) : [], + ctx.db.adminUser.findMany({ select: { id: true, name: true } }), + ]); + const adminName = new Map(admins.map((a) => [a.id, a.name])); + const revClients = rev.length ? new Map((await ctx.db.client.findMany({ where: { id: { in: [...new Set(rev.map((r) => r.clientId))] } }, include: { user: true } })).map((c) => [c.id, name(c.user)])) : new Map(); + const rows = [ + ...acc.map((a) => { + const diff = a.suggestedAmount ? Math.abs(a.amount - a.suggestedAmount) / Math.max(1, a.suggestedAmount) : 0; + return { + id: a.id, + at: a.createdAt, + type: a.kind === 'BONUS' ? ('bonus' as const) : ('accrual' as const), + clientId: a.clientId, + client: name(a.client.user), + amount: a.amount, + suggested: a.suggestedAmount, + highlight: a.kind === 'ORDER' && diff * 100 > s['points.diffHighlightPct'], + status: a.status as string, + what: a.kind === 'BONUS' ? ((a.bonusType?.name as Record) ?? null) : ((a.category?.name as Record) ?? null), + orderRef: a.orderRef, + orderAmount: a.orderAmount, + tier: a.tier, + ratePct: a.ratePct, + author: a.createdById ? (adminName.get(a.createdById) ?? '—') : null, + channel: a.channel as string, + reason: a.reason, + comment: a.commentForClient, + canReverse: a.status === 'PENDING' || a.status === 'ACTIVE', + }; + }), + ...red.map((r) => ({ + id: r.id, + at: r.confirmedAt ?? r.requestedAt, + type: 'redemption' as const, + clientId: r.clientId, + client: name(r.client.user), + amount: -r.amount, + suggested: null, + highlight: false, + status: r.status as string, + what: null, + orderRef: r.order ? `#${r.order.number}` : r.orderRef, + orderAmount: r.orderAmount, + tier: null, + ratePct: null, + author: r.confirmedById ? (adminName.get(r.confirmedById) ?? '—') : null, + channel: r.channel as string, + reason: r.reason, + comment: r.note, + canReverse: r.status === 'CONFIRMED', + })), + ...rev.map((r) => ({ + id: r.id, + at: r.createdAt, + type: 'reversal' as const, + clientId: r.clientId, + client: revClients.get(r.clientId) ?? '—', + amount: r.targetType === 'ACCRUAL' ? -r.amount : r.amount, + suggested: null, + highlight: r.debt > 0, + status: r.targetType, + what: null, + orderRef: null, + orderAmount: null, + tier: null, + ratePct: null, + author: r.createdById ? (adminName.get(r.createdById) ?? '—') : null, + channel: r.channel as string, + reason: r.reason, + comment: r.debt > 0 ? `debt:${r.debt}` : null, + canReverse: false, + })), + ]; + return rows.sort((a, b) => b.at.getTime() - a.at.getTime()).slice(0, input.take); + }), + + /** CSV: телефон, категория, сумма заказа, дата, номер заказа. dryRun — предпросмотр с ошибками */ + import: pointsProcedure + .input(z.object({ dryRun: z.boolean(), rows: z.array(z.object({ phone: z.string().max(40), category: z.string().max(40), orderAmount: z.number().int().min(0), date: z.string().max(20), orderRef: z.string().max(60) })).max(1000) })) + .mutation(async ({ ctx, input }) => { + const s = await getSettings(ctx.db); + const now = ctx.svc.now(); + const cats = await ctx.db.pointsCategory.findMany(); + const seen = new Set(); + const out: { line: number; clientId?: string; client?: string; amount?: number; error?: string }[] = []; + for (const [i, r] of input.rows.entries()) { + const line = i + 1; + const digits = r.phone.replace(/[^\d]/g, ''); + const cat = cats.find((c) => c.code === r.category.trim().toLowerCase() || Object.values(c.name as Record).some((n) => n.toLowerCase() === r.category.trim().toLowerCase())); + const date = new Date(`${r.date.trim()}T12:00:00+04:00`); + const ref = r.orderRef.trim(); + if (!cat) { + out.push({ line, error: 'category' }); + continue; + } + if (Number.isNaN(date.getTime())) { + out.push({ line, error: 'date' }); + continue; + } + const users = digits.length >= 8 ? await ctx.db.user.findMany({ where: { phone: { endsWith: digits.slice(-8) } }, include: { client: true } }) : []; + const cl = users.find((u) => u.client)?.client; + if (!cl) { + out.push({ line, error: 'client' }); + continue; + } + if (!ref || seen.has(ref) || (await ctx.db.pointsAccrual.findFirst({ where: { orderRef: ref, status: { not: 'REVERSED' } } }))) { + out.push({ line, error: 'duplicate' }); + continue; + } + seen.add(ref); + const p = await previewAccrual(ctx.db, { clientId: cl.id, categoryId: cat.id, orderAmount: r.orderAmount, serviceDate: date }, s, now); + const u = users.find((x) => x.client)!; + if (!input.dryRun) { + await accruePoints(ctx.svc, { clientId: cl.id, kind: 'ORDER', categoryId: cat.id, orderAmount: r.orderAmount, orderRef: ref, serviceDate: date, channel: 'CSV', idempotencyKey: `csv:${ref}` }, ctx.actor); + } + out.push({ line, clientId: cl.id, client: name(u), amount: p.suggested }); + } + return { rows: out, total: out.reduce((sum, r) => sum + (r.amount ?? 0), 0), errors: out.filter((r) => r.error).length }; + }), + + /** Очередь «Предложено системой» */ + suggestions: pointsProcedure.query(async ({ ctx }) => { + const list = await ctx.db.pointsSuggestion.findMany({ where: { status: 'OPEN' }, orderBy: { createdAt: 'asc' }, include: { client: { include: { user: true } } }, take: 200 }); + const admins = new Map((await ctx.db.adminUser.findMany({ select: { id: true, name: true } })).map((a) => [a.id, a.name])); + return list.map((x) => { + const p = x.payload as Record; + return { id: x.id, clientId: x.clientId, name: name(x.client.user), mechanic: x.mechanic, amount: x.suggestedAmount, payload: p, by: typeof p.adminId === 'string' ? (admins.get(p.adminId) ?? null) : null, createdAt: x.createdAt }; + }); + }), + acceptSuggestion: superProcedure + .input(z.object({ id: z.string(), amount: z.number().int().min(1).optional(), reason: z.string().trim().max(500).nullable().optional() })) + .mutation(({ ctx, input }) => acceptSuggestion(ctx.svc, input, { adminId: ctx.admin.id, owner: true })), + rejectSuggestion: superProcedure.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500) })).mutation(({ ctx, input }) => rejectSuggestion(ctx.svc, input, { adminId: ctx.admin.id, owner: true })), + + /** Справочники — только суперадмин */ + saveCategory: superProcedure + .input(z.object({ id: z.string().optional(), code: z.string().trim().regex(/^[a-z_]{2,30}$/), name: L5, rateBarev: z.number().int().min(0).max(100), ratePlus: z.number().int().min(0).max(100), rateElite: z.number().int().min(0).max(100), active: z.boolean(), sortOrder: z.number().int().default(0) })) + .mutation(async ({ ctx, input }) => { + const { id, ...data } = input; + return id ? ctx.db.pointsCategory.update({ where: { id }, data }) : ctx.db.pointsCategory.create({ data }); + }), + saveBonusType: superProcedure + .input(z.object({ id: z.string().optional(), code: z.string().trim().regex(/^[a-z_]{2,30}$/), name: L5, defaultAmount: z.number().int().min(0), ttlDays: z.number().int().min(1).max(1000).nullable(), active: z.boolean(), sortOrder: z.number().int().default(0) })) + .mutation(async ({ ctx, input }) => { + const { id, ...data } = input; + return id ? ctx.db.pointsBonusType.update({ where: { id }, data }) : ctx.db.pointsBonusType.create({ data }); + }), + + /** Отчёт за период: начислено, потрачено, сгорело, отменено, обязательства, redemption rate, повторные заказы, топы */ + report: superProcedure + .input(z.object({ from: z.coerce.date(), to: z.coerce.date(), categoryId: z.string().optional(), agencyId: z.string().optional() })) + .query(async ({ ctx, input }) => { + const at = { gte: input.from, lt: input.to }; + const clientWhere = input.agencyId ? { agencyId: input.agencyId } : {}; + const accWhere = { createdAt: at, categoryId: input.categoryId, client: clientWhere }; + const [accrued, spent, expired, reversed, liabilities, byCategory, topClients, topAgencies, payers] = await Promise.all([ + ctx.db.pointsAccrual.aggregate({ where: accWhere, _sum: { amount: true } }), + ctx.db.pointsRedemption.aggregate({ where: { status: 'CONFIRMED', confirmedAt: at, client: clientWhere }, _sum: { amount: true } }), + // сгоревшее — по начислениям этого периода (дату сгорания журнал хранит в статусе) + ctx.db.pointsAccrual.aggregate({ where: { ...accWhere, expiredAmount: { gt: 0 } }, _sum: { expiredAmount: true } }), + ctx.db.pointsReversal.aggregate({ where: { targetType: 'ACCRUAL', createdAt: at }, _sum: { amount: true } }), + ctx.db.pointsAccrual.aggregate({ where: { status: { in: ['ACTIVE', 'PENDING'] }, categoryId: input.categoryId, client: clientWhere }, _sum: { remaining: true } }), + ctx.db.$queryRaw<{ code: string; name: Record; points: number; revenue: number }[]>` + SELECT pc.code, pc.name, COALESCE(SUM(a.amount),0)::int AS points, COALESCE(SUM(COALESCE(a."orderAmount",0) - a."excludedAmount" - a."paidWithPoints"),0)::int AS revenue + FROM "PointsAccrual" a JOIN "PointsCategory" pc ON pc.id = a."categoryId" + WHERE a.kind = 'ORDER' AND a.status <> 'REVERSED' AND a."createdAt" >= ${input.from} AND a."createdAt" < ${input.to} + GROUP BY pc.code, pc.name`, + ctx.db.client.findMany({ where: { pointsActive: { gt: 0 }, ...clientWhere }, orderBy: { pointsActive: 'desc' }, take: 10, include: { user: true } }), + ctx.db.$queryRaw<{ name: string; n: number }[]>` + SELECT ag.name, COUNT(*)::int AS n FROM "PointsAccrual" a + JOIN "PointsBonusType" bt ON bt.id = a."bonusTypeId" AND bt.code = 'housewarming' + JOIN "Client" c ON c.id = a."clientId" JOIN "Agency" ag ON ag.id = c."agencyId" + WHERE a."createdAt" >= ${input.from} AND a."createdAt" < ${input.to} + GROUP BY ag.name ORDER BY n DESC LIMIT 10`, + // повторный заказ за 30 дней: у клиентов с бариками и без + ctx.db.$queryRaw<{ with_points: boolean; clients: number; repeat: number; multi: number }[]>` + WITH firsts AS ( + SELECT o."clientId", MIN(o."createdAt") AS first_at, COUNT(DISTINCT s."pointsCategoryId") AS cats + FROM "Order" o JOIN "Service" s ON s.id = o."serviceId" + WHERE o.status <> 'CANCELLED' AND o."createdAt" >= ${input.from} AND o."createdAt" < ${input.to} + GROUP BY o."clientId" + ) + SELECT (c."pointsActive" + c."pointsPending" > 0 OR EXISTS (SELECT 1 FROM "PointsAccrual" a WHERE a."clientId" = f."clientId")) AS with_points, + COUNT(*)::int AS clients, + COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Order" o2 WHERE o2."clientId" = f."clientId" AND o2.status <> 'CANCELLED' AND o2."createdAt" > f.first_at AND o2."createdAt" <= f.first_at + interval '30 days'))::int AS repeat, + COUNT(*) FILTER (WHERE f.cats >= 2)::int AS multi + FROM firsts f JOIN "Client" c ON c.id = f."clientId" + GROUP BY 1`, + ]); + const a = accrued._sum.amount ?? 0; + const sp = spent._sum.amount ?? 0; + return { + accrued: a, + spent: sp, + expired: expired._sum.expiredAmount ?? 0, + reversed: reversed._sum.amount ?? 0, + liabilities: liabilities._sum.remaining ?? 0, + redemptionRate: a ? Math.round((sp / a) * 1000) / 10 : 0, + byCategory: byCategory.map((c) => ({ ...c, costPct: c.revenue ? Math.round((c.points / c.revenue) * 1000) / 10 : 0 })), + topClients: topClients.map((c) => ({ id: c.id, name: name(c.user), balance: c.pointsActive })), + topAgencies, + retention: payers.map((p) => ({ withPoints: p.with_points, clients: p.clients, repeatPct: p.clients ? Math.round((p.repeat / p.clients) * 1000) / 10 : 0, multi: p.multi })), + }; + }), +}); diff --git a/apps/api/src/routers/backoffice/providers.ts b/apps/api/src/routers/backoffice/providers.ts index bca09ae..a4828e0 100644 --- a/apps/api/src/routers/backoffice/providers.ts +++ b/apps/api/src/routers/backoffice/providers.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { averageRating, DAY, LOCALES, PROVIDER_ROLES, startOfYerevanDay } from '@barev/core'; import type { Prisma } from '@barev/db'; -import { orderPayout, rateLabel } from '@barev/services'; +import { getSettings, orderPayout, rateLabel } from '@barev/services'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { env } from '../../env.ts'; @@ -30,7 +30,7 @@ const profileInput = z.object({ rate: z.object({ rateType: z.enum(['FIXED', 'PERCENT']), rateValue: z.number().int().min(0), payoutPresetId: z.string().nullable() }).optional(), }); -const payoutInclude = { company: { select: { rateType: true, rateValue: true } }, payoutPreset: { select: { name: true, rules: true } } } as const; +const payoutInclude = { company: { select: { deal: true, fotPct: true } }, payoutPreset: { select: { name: true, rules: true } } } as const; export const boProvidersRouter = router({ list: providersProcedure @@ -97,7 +97,7 @@ export const boProvidersRouter = router({ where: { id: input.id }, include: { ...payoutInclude, - company: { select: { id: true, name: true, rateType: true, rateValue: true } }, + company: { select: { id: true, name: true, deal: true, fotPct: true } }, user: { select: { username: true, firstName: true } }, districts: true, schedule: { orderBy: [{ weekday: 'asc' }, { startMinute: 'asc' }] }, @@ -127,19 +127,20 @@ export const boProvidersRouter = router({ // деньги: заработано (по ставке исполнителя) минус выплачено = к выплате let money = null; if (ctx.isSuper) { + const fot = { fotPct: (await getSettings(ctx.db))['partner.fotPct'] }; const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)); const [doneAll, payouts] = await Promise.all([ ctx.db.order.findMany({ where: { providerId: p.id, ...DONE }, select: { completedAt: true, totalPrice: true, basePrice: true, params: true, priceBreakdown: true, service: { select: { pricing: true, paramsSchema: true } } } }), ctx.db.payout.findMany({ where: { providerId: p.id }, orderBy: { createdAt: 'desc' } }), ]); - const earn = doneAll.map((o) => ({ at: o.completedAt!, sum: orderPayout(o, p) })); + const earn = doneAll.map((o) => ({ at: o.completedAt!, sum: orderPayout(o, p, fot) })); const sum = (f: (x: (typeof earn)[number]) => boolean) => earn.filter(f).reduce((s, x) => s + x.sum, 0); const earnedTotal = sum(() => true); const paidTotal = payouts.filter((x) => x.status === 'PAID').reduce((s, x) => s + x.amount, 0); const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: payouts.map((x) => x.createdById).filter((x): x is string => !!x) } }, select: { id: true, name: true } })).map((a) => [a.id, a.name])); money = { - rate: rateLabel(p), + rate: rateLabel(p, fot), thisMonth: sum((x) => x.at >= monthStart), prevMonth: sum((x) => x.at >= prevMonthStart && x.at < monthStart), earnedTotal, diff --git a/apps/api/src/routers/client.ts b/apps/api/src/routers/client.ts index f71f130..7b85c70 100644 --- a/apps/api/src/routers/client.ts +++ b/apps/api/src/routers/client.ts @@ -1,5 +1,9 @@ import { defaultParams, discountFor, LOCALES } from '@barev/core'; import { rateOrder, + clientPoints, + pointsHistory, + redeemQuote, + requestRedemption, applyAgencyCode, availableSlots, cancelByClient, @@ -54,6 +58,8 @@ export const clientRouter = router({ membership, agencyName: agency?.name ?? null, giftUsed: !!ctx.client.giftUsedAt, + /** барики: доступный баланс */ + points: ctx.client.pointsActive, cardCode: ctx.client.cardCode, barevPlusPrice: settings['barevPlus.monthlyPrice'], freeCancelHours: settings['cancel.freeHours'], @@ -185,7 +191,9 @@ export const clientRouter = router({ .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 }; + // барики: сколько можно списать в этом заказе (с промокодом — нельзя) + const points = await redeemQuote(ctx.db, ctx.client.id, r.quote.totalPrice, await getSettings(ctx.db)); + return { ...r.quote, promo: r.promo ? { code: r.promo.code, discountPct: r.promo.discountPct } : null, points }; }), /** ORD-3 */ @@ -212,6 +220,8 @@ export const clientRouter = router({ repeatOfId: z.string().optional(), /** промокод: применится, если выгоднее скидки подписки */ promoCode: z.string().trim().max(40).optional(), + /** оплатить часть бариками (с промокодом нельзя) */ + usePoints: z.number().int().min(0).max(10_000_000).optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -219,6 +229,18 @@ export const clientRouter = router({ return { id: order.id, number: order.number }; }), + /** Барики: экран, история, запрос на оплату заказа вне приложения или уже созданного заказа */ + points: router({ + summary: clientProcedure.query(({ ctx }) => clientPoints(ctx.svc, ctx.client.id)), + history: clientProcedure.input(z.object({ before: z.date().optional() }).optional()).query(({ ctx, input }) => pointsHistory(ctx.db, ctx.client.id, { take: 30, before: input?.before })), + request: consentedClientProcedure + .input(z.object({ amount: z.number().int().min(1).max(10_000_000), orderId: z.string().optional(), note: z.string().trim().max(300).optional() })) + .mutation(async ({ ctx, input }) => { + const r = await requestRedemption(ctx.svc, { clientId: ctx.client.id, ...input }); + return { id: r.id }; + }), + }), + orders: router({ list: clientProcedure.query(({ ctx }) => listClientOrders(ctx.db, ctx.client.id)), get: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => clientOrder(ctx.db, ctx.client.id, input.id)), diff --git a/apps/api/src/routers/pro.ts b/apps/api/src/routers/pro.ts index e525108..75b73c9 100644 --- a/apps/api/src/routers/pro.ts +++ b/apps/api/src/routers/pro.ts @@ -16,13 +16,38 @@ import { partnerCabinet, redeemOffer, removePartnerMember, + companyStaffCandidates, + exchangeProWebLogin, + getSettings, + partnerAddStaff, + partnerCabinetData, + partnerDeclineOrder, + partnerRemoveManager, + partnerResetStaffInvite, + partnerSaveSettings, + partnerUnassign, + partnerUpdateStaff, + revokeProWebSession, } from '@barev/services'; +import { LOCALES } from '@barev/core'; +import { env } from '../env.ts'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { signedUrl } from '../infra.ts'; -import { proUserProcedure, providerProcedure, router } from '../trpc.ts'; +import { proUserProcedure, providerProcedure, publicProcedure, router } from '../trpc.ts'; const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const botLink = (code: string) => `https://t.me/${env.PRO_BOT_USERNAME}?start=${code}`; +const staffInput = z.object({ + name: z.string().trim().min(1).max(100), + phone: z.string().trim().max(30).nullable().optional(), + languages: z.array(z.enum(LOCALES)).min(1), + districtIds: z.array(z.string()).max(30), + schedule: z.array(z.object({ weekday: z.number().int().min(0).max(6), startMinute: z.number().int().min(0).max(1440), endMinute: z.number().int().min(0).max(1440) })).max(21), + maxOrdersPerDay: z.number().int().min(0).max(20).nullable().optional(), + maxHoursPerDay: z.number().int().min(0).max(24).nullable().optional(), +}); +const co = z.object({ companyId: z.string() }); /** Кабинет партнёра клуба: собственник и сотрудники */ const partnerRouter = router({ @@ -50,6 +75,7 @@ export const proRouter = router({ : null, companies: ctx.identity.companies.map((c) => ({ id: c.id, name: c.name })), partners: ctx.identity.partners, + web: ctx.web, }; }), @@ -98,15 +124,52 @@ export const proRouter = router({ return { url: await signedUrl(file.storageKey) }; }), - /** PRO-7: диспетчер компании */ + /** Вход в кабинет из браузера: одноразовый код из бота → токен на 30 дней */ + webLogin: publicProcedure.input(z.object({ code: z.string().min(10).max(100) })).mutation(async ({ ctx, input }) => ({ token: await exchangeProWebLogin(ctx.svc, input.code) })), + webLogout: publicProcedure.mutation(async ({ ctx }) => { + const auth = ctx.req.headers.authorization; + if (auth?.startsWith('pro ')) await revokeProWebSession(ctx.db, auth.slice(4)); + }), + + /** PRO-7: менеджер партнёра (диспетчер компании) */ dispatcher: router({ board: proUserProcedure.input(z.object({ companyId: z.string() })).query(({ ctx, input }) => companyBoard(ctx.svc, input.companyId, ctx.identity.user.id)), + /** Кабинет: заказы, сотрудники, доход, настройки */ + cabinet: proUserProcedure.input(co).query(async ({ ctx, input }) => { + const d = await partnerCabinetData(ctx.svc, input.companyId, ctx.identity.user.id); + return { + ...d, + company: { ...d.company, managerInvite: undefined, managerLink: botLink(d.company.managerInvite) }, + staff: d.staff.map((p) => ({ ...p, inviteCode: undefined, inviteLink: p.inviteCode ? botLink(p.inviteCode) : null })), + }; + }), staffForOrder: proUserProcedure.input(z.object({ companyId: z.string(), orderId: z.string() })).query(async ({ ctx, input }) => { if (!ctx.identity.companies.some((c) => c.id === input.companyId)) throw new TRPCError({ code: 'FORBIDDEN' }); - return companyStaffForOrder(ctx.svc, input.companyId, input.orderId); + const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, select: { companyId: true } }); + if (order?.companyId !== input.companyId) throw new TRPCError({ code: 'NOT_FOUND' }); + return companyStaffCandidates(ctx.db, input.companyId, input.orderId, await getSettings(ctx.db)); }), assign: proUserProcedure .input(z.object({ companyId: z.string(), orderId: z.string(), providerId: z.string() })) .mutation(({ ctx, input }) => dispatcherAssign(ctx.svc, { ...input, dispatcherUserId: ctx.identity.user.id })), + /** Отказаться от заказа — он вернётся оператору */ + decline: proUserProcedure.input(co.extend({ orderId: z.string() })).mutation(({ ctx, input }) => partnerDeclineOrder(ctx.svc, { ...input, userId: ctx.identity.user.id })), + /** Снять сотрудника с заказа (переназначить) */ + unassign: proUserProcedure.input(co.extend({ orderId: z.string() })).mutation(({ ctx, input }) => partnerUnassign(ctx.svc, { ...input, userId: ctx.identity.user.id })), + addStaff: proUserProcedure.input(co.extend({ staff: staffInput })).mutation(async ({ ctx, input }) => { + const r = await partnerAddStaff(ctx.svc, { companyId: input.companyId, userId: ctx.identity.user.id, staff: input.staff }); + return { id: r.id, inviteLink: botLink(r.inviteCode) }; + }), + updateStaff: proUserProcedure + .input(co.extend({ providerId: z.string(), staff: staffInput, active: z.boolean().optional() })) + .mutation(({ ctx, input }) => partnerUpdateStaff(ctx.svc, { ...input, userId: ctx.identity.user.id })), + resetStaffInvite: proUserProcedure.input(co.extend({ providerId: z.string() })).mutation(async ({ ctx, input }) => { + const r = await partnerResetStaffInvite(ctx.svc, { ...input, userId: ctx.identity.user.id }); + return { inviteLink: botLink(r.inviteCode) }; + }), + saveSettings: proUserProcedure + .input(co.extend({ autoAssign: z.boolean(), travelGapMinutes: z.number().int().min(0).max(240) })) + .mutation(({ ctx, input }) => partnerSaveSettings(ctx.svc, { ...input, userId: ctx.identity.user.id })), + removeManager: proUserProcedure.input(co.extend({ managerId: z.string() })).mutation(({ ctx, input }) => partnerRemoveManager(ctx.svc, { ...input, userId: ctx.identity.user.id })), }), }); diff --git a/apps/api/src/routers/public.ts b/apps/api/src/routers/public.ts index bfb79d4..17d3dd7 100644 --- a/apps/api/src/routers/public.ts +++ b/apps/api/src/routers/public.ts @@ -83,9 +83,10 @@ export const publicRouter = router({ .input( z.object({ params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))])), - preferredAt: z.iso.datetime().transform((v) => new Date(v)), - districtCode: z.string().max(40), - addressText: z.string().max(300), + // обязательны только параметры уборки и телефон; остальное бот доспросит или уточнит оператор + preferredAt: z.iso.datetime().transform((v) => new Date(v)).optional(), + districtCode: z.string().max(40).optional(), + addressText: z.string().max(300).optional(), note: z.string().max(1000).optional(), phone: z.string().max(30), name: z.string().max(80).optional(), diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 4ebce3c..9b0b3b6 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, startTextOverrides } from '@barev/services'; +import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffChecklistItem, 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'; @@ -187,6 +187,29 @@ app.post<{ Params: { providerId: string } }>('/files/bo/provider-doc/:providerId return { id: doc.id }; }); +/** Команда загружает фото к пункту чек-листа за исполнителя и отмечает пункт */ +app.post<{ Params: { orderId: string } }>('/files/bo/checklist-photo/:orderId', async (req, reply) => { + const staff = await staffFromCookie(req); + if (!staff || !canDispatch(staff)) return reply.code(staff ? 403 : 401).send({ error: staff ? 'ROLE_FORBIDDEN' : 'ADMIN_LOGIN_REQUIRED' }); + const order = await db.order.findUnique({ where: { id: req.params.orderId } }); + if (!order) return reply.code(404).send({ error: 'NOT_FOUND' }); + const part = await req.file(); + if (!part) return reply.code(400).send({ error: 'NO_FILE' }); + const code = multipartField(part, 'code'); + if (!code) return reply.code(400).send({ error: 'BAD_INPUT' }); + if (!IMAGE_TYPES.has(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' }); + const body = await part.toBuffer(); + const key = await putObject(`orders/${order.id}`, body, part.mimetype); + const file = await db.orderFile.create({ data: { orderId: order.id, kind: 'PHOTO_REPORT', storageKey: key, mime: part.mimetype, size: body.length, uploadedBy: 'ADMIN', uploadedById: staff.admin.id } }); + try { + await staffChecklistItem(svc, { orderId: order.id, adminId: staff.admin.id, code, checked: true, fileId: file.id }); + } catch (e) { + if (e instanceof DomainError) return reply.code(400).send({ error: e.code }); + throw e; + } + return { fileId: file.id }; +}); + app.get<{ Params: { docId: string } }>('/files/bo/provider-doc-file/:docId', async (req, reply) => { const staff = await staffFromCookie(req); if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' }); diff --git a/apps/api/src/trpc.ts b/apps/api/src/trpc.ts index 58bd2c3..b624320 100644 --- a/apps/api/src/trpc.ts +++ b/apps/api/src/trpc.ts @@ -2,7 +2,7 @@ import type {} from '@fastify/cookie'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { initTRPC, TRPCError } from '@trpc/server'; -import { DomainError, getClientByTelegramId, proIdentity, startClient } from '@barev/services'; +import { DomainError, getClientByTelegramId, proIdentity, proWebSessionUser, startClient } from '@barev/services'; import superjson from 'superjson'; import { ZodError } from 'zod'; import { ADMIN_COOKIE, findSession } from './auth/admin.ts'; @@ -86,12 +86,21 @@ export const consentedClientProcedure = clientProcedure.use(async ({ ctx, next } /** Mini App исполнителя: initData бота исполнителей; пользователь должен быть привязан инвайтом */ export const proUserProcedure = publicProcedure.use(async ({ ctx, next }) => { + // кабинет менеджера партнёра в браузере: токен вместо initData (вход по одноразовой ссылке из бота) + const auth = ctx.req.headers.authorization; + if (auth?.startsWith('pro ')) { + const telegramId = await proWebSessionUser(ctx.db, auth.slice(4), ctx.svc.now()); + if (!telegramId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'WEB_SESSION_EXPIRED' }); + const identity = await proIdentity(ctx.db, telegramId); + if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' }); + return next({ ctx: { tgUser: { id: Number(telegramId), first_name: identity.user.firstName ?? '' }, identity, web: true } }); + } const init = checkInitData(ctx.req, env.proBotToken); const identity = await proIdentity(ctx.db, BigInt(init.user.id)); if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' }); } - return next({ ctx: { tgUser: init.user, identity } }); + return next({ ctx: { tgUser: init.user, identity, web: false } }); }); export const providerProcedure = proUserProcedure.use(async ({ ctx, next }) => { diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx index 358e639..35a4e4c 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 { ServicePartnerCard, ServicePartners } from './pages/ServicePartners.tsx'; +import { Points } from './pages/Points.tsx'; import { Products, ServiceEditor } from './pages/Products.tsx'; import { Broadcasts, Journal, Settings } from './pages/Admin5.tsx'; import { MoveCard, Moves, Requests } from './pages/Moves.tsx'; @@ -57,7 +59,9 @@ const NAV: NavItem[] = [ { path: '/chats', key: 'nav.chats', icon: 'chats', roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' }, { path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT', 'CONCIERGE'], count: 'orders' }, { path: '/providers', key: 'nav.providers', icon: 'providers', roles: ['SUPPORT'], group: 'work' }, + { path: '/points', key: 'nav.points', icon: 'gift', roles: [], group: 'work' }, { path: '/agencies', key: 'nav.agencies', icon: 'agencies', roles: [], group: 'partners' }, + { path: '/service-partners', key: 'nav.servicePartners', icon: 'providers', 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', roles: ['SUPPORT'], group: 'setup' }, @@ -95,6 +99,7 @@ function Shell({ me }: { me: Me }) { const leadId = match('/crm/:id', path)?.id; const agencyId = match('/agencies/:id', path)?.id; const venueId = match('/venues/:id', path)?.id; + const servicePartnerId = match('/service-partners/: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'); @@ -112,6 +117,9 @@ function Shell({ me }: { me: Me }) { else if (leadId && canCrm) page = ; else if (path === '/agencies' && isSuper) page = ; else if (agencyId && isSuper) page = ; + else if (path === '/points' && isSuper) page = ; + else if (path === '/service-partners' && isSuper) page = ; + else if (servicePartnerId && isSuper) page = ; else if (path === '/venues' && isSuper) page = ; else if (venueId && isSuper) page = ; else if (path === '/products' && canProducts) page = ; diff --git a/apps/backoffice/src/admin5.css b/apps/backoffice/src/admin5.css index 316cbfa..703f8dc 100644 --- a/apps/backoffice/src/admin5.css +++ b/apps/backoffice/src/admin5.css @@ -123,3 +123,40 @@ border-radius: 16px; background: var(--bo-soft); } + +/* ───── барики ───── */ +.bo-pt-tabs { + margin-bottom: 16px; + overflow-x: auto; +} +.bo-pt-tabs button { + flex: none; + padding: 0 14px; +} +.bo-pt-pick { + cursor: pointer; + border-radius: 14px; +} +.bo-pt-pick[data-on] { + background: var(--bo-soft); +} +.bo-pt-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 8px; +} +.bo-pt-filters .bo-select, +.bo-pt-filters .bo-input { + width: auto; + min-width: 140px; +} +.bo-table tr[data-hl] td { + background: color-mix(in srgb, var(--bc-perks) 45%, transparent); +} +.bo-alert--warn { + background: var(--bc-perks); + color: var(--bc-perks-ink); + cursor: default; +} diff --git a/apps/backoffice/src/i18n.tsx b/apps/backoffice/src/i18n.tsx index 2f579fd..887ed82 100644 --- a/apps/backoffice/src/i18n.tsx +++ b/apps/backoffice/src/i18n.tsx @@ -43,8 +43,8 @@ const ru = { 'nav.chats': 'Чаты', 'nav.orders': 'Заказы', 'nav.providers': 'Исполнители', - 'nav.agencies': 'Агентства', - 'nav.venues': 'Заведения', + 'nav.agencies': 'Риэлторские агентства', + 'nav.venues': 'Партнёры клуба', 'nav.crm': 'CRM', 'nav.products': 'Продукты', 'nav.journal': 'Журнал', @@ -656,7 +656,7 @@ const ru = { 'st.saveN': 'Сохранить ({n})', 'st.group.orders': 'Заказы', 'st.group.subs': 'Подписка', - 'st.group.agencies': 'Агентства', + 'st.group.agencies': 'Риэлторские агентства', 'st.group.sales': 'Бонус продажников', 'st.group.site': 'Цены на сайте', 'st.group.other': 'Прочее', @@ -809,22 +809,22 @@ const ru = { 'venue.cat.shop': 'Магазин', 'venue.cat.beauty': 'Красота', 'venue.cat.other': 'Другое', - 'ag.title': 'Агентства', - 'ag.add': 'Новое агентство', - 'ag.editTitle': 'Агентство', - 'ag.empty': 'Агентств пока нет.', - 'ag.col.name': 'Агентство', + 'ag.title': 'Риэлторские агентства', + 'ag.add': 'Новое риэлторское агентство', + 'ag.editTitle': 'Риэлторское агентство', + 'ag.empty': 'Риэлторских агентств пока нет.', + 'ag.col.name': 'Риэлторское агентство', 'ag.col.clients': 'Клиенты', 'ag.col.gifts': 'Подарки', 'ag.col.month': 'Вознаграждение, месяц', 'ag.col.balance': 'К выплате', 'ag.hint': 'Вознаграждение — % от оплаченных заказов клиентов агентства, пока они закреплены за ним.', 'ag.inactive': 'выключено', - 'ag.back': 'Все агентства', + 'ag.back': 'Все риэлторские агентства', 'ag.pct': '{n}% с заказов клиентов', 'ag.byS': 'подключил(а) {name}', 'ag.link': 'Ссылка для клиентов', - 'ag.linkHint': 'По ссылке клиент открывает бота, получает подарочный месяц и закрепляется за агентством. Или вводит код {code} командой /code.', + 'ag.linkHint': 'По ссылке клиент открывает бота, получает подарок агентства и закрепляется за ним. Или вводит код {code} командой /code.', 'ag.clients': 'Клиенты', 'ag.giftsN': 'подарков: {n}', 'ag.noClients': 'Клиентов пока нет.', @@ -838,7 +838,7 @@ const ru = { 'ag.sales': 'Подключил продажник', 'ag.contactPh': 'Имя, телефон, Telegram', 'ag.active': 'Агентство работает (код принимается)', - 'vn.title': 'Заведения', + 'vn.title': 'Партнёры клуба', 'vn.add': 'Новое заведение', 'vn.editTitle': 'Заведение', 'vn.empty': 'Заведений пока нет.', @@ -1169,6 +1169,279 @@ const ru = { 'audit.bo.chats.openFor': 'открыл(а) чат с клиентом', 'audit.bo.chats.templates.save': 'изменил(а) шаблон ответа', 'audit.bo.chats.templates.remove': 'удалил(а) шаблон ответа', + 'nav.servicePartners': 'Партнёры услуг', + 'st.group.partners': 'Партнёры услуг', + 'ag.giftPlan': 'Подарок новым клиентам', + 'ag.giftMonths': 'На сколько', + 'ag.giftHint': 'Действует для новых клиентов; у тех, кто уже получил подарок, ничего не меняется.', + 'ag.noGift': 'без подарка', + 'ag.giftValue': '{plan} на {n} мес.', + 'ag.monthsN': '{n} мес.', + 'ord.partner': 'партнёр', + 'ord.best': 'выгоднее всего', + 'ord.partnerFree': 'свободно {n} из {of}', + 'ord.candMoney': 'Платим {cost} · нам остаётся {margin}', + 'prv.ratePartnerPays': 'Зарплату платит партнёр', + 'rq.missing': 'не хватает данных', + 'rq.noTime': 'не указано', + 'rq.noDistrict': 'район не указан', + 'rq.noAddress': 'адрес не указан', + 'sp.title': 'Партнёры услуг', + 'sp.add': 'Новый партнёр', + 'sp.editTitle': 'Партнёр услуг', + 'sp.lead': 'Клининговые компании со своими сотрудниками. Менеджер партнёра получает ссылку, сам добавляет сотрудников и распределяет заказы. Заказ партнёру назначает оператор в карточке заказа.', + 'sp.empty': 'Партнёров пока нет.', + 'sp.col.name': 'Партнёр', + 'sp.col.staff': 'Сотрудники', + 'sp.col.active': 'Заказы в работе', + 'sp.col.month': 'Доход партнёра, месяц', + 'sp.col.balance': 'К выплате', + 'sp.noManager': 'нет менеджера', + 'sp.auto': 'автораспределение', + 'sp.back': 'Все партнёры услуг', + 'sp.terms.COMMISSION': 'как Uber: наши {pct}% с заказа', + 'sp.terms.PROFIT_SHARE': 'расходы наши: ФОТ {fot}%, налог {tax}%, партнёру {pct}% прибыли', + 'sp.managerLink': 'Ссылка для менеджера', + 'sp.managerLinkHint': 'Отдайте ссылку менеджеру партнёра: он откроет рабочий бот и получит кабинет — заказы, сотрудники, автораспределение, доход. Кабинет открывается и на компьютере (кнопка в боте).', + 'sp.resetLink': 'Новая ссылка', + 'sp.resetConfirm': 'Сделать новую ссылку? Старая перестанет работать, подключённые менеджеры останутся.', + 'sp.linkReset': 'Ссылка обновлена', + 'sp.managers': 'Менеджеры', + 'sp.noManagers': 'Менеджер ещё не подключился по ссылке.', + 'sp.managerSince': 'с {date}', + 'sp.remove': 'Убрать', + 'sp.removeManagerConfirm': 'Убрать {name} из менеджеров партнёра?', + 'sp.staff': 'Сотрудники', + 'sp.staffHint': 'добавляет менеджер партнёра', + 'sp.noStaff': 'Сотрудников пока нет.', + 'sp.limitOrders': 'до {n} заказов в день', + 'sp.limitHours': 'до {n} ч в день', + 'sp.notLinked': 'не вошёл в бот', + 'sp.off': 'выключен', + 'sp.orders': 'Заказы в работе', + 'sp.noOrders': 'Сейчас заказов у партнёра нет.', + 'sp.waitingStaff': 'ждёт сотрудника', + 'sp.toPay': 'Доход партнёра к выплате', + 'sp.byOrder': 'Доход по заказам', + 'sp.noIncome': 'Доход появится, когда клиент оплатит выполненный заказ.', + 'sp.paid': 'оплачено {sum}', + 'sp.fotTax': 'ФОТ {fot}, налог {tax}', + 'sp.payoutTitle': 'Выплата партнёру', + 'sp.phone': 'Телефон', + 'sp.service': 'Услуга', + 'sp.cleaningOnly': 'Клининг (пока только он)', + 'sp.deal': 'Модель сотрудничества', + 'sp.deal.COMMISSION': 'Как Uber: наш % с заказа', + 'sp.deal.PROFIT_SHARE': 'Расходы наши: % партнёру', + 'sp.dealHint.COMMISSION': 'Мы забираем свой % с оплаченного заказа, остальное — партнёру. Зарплаты сотрудникам и расходы — его.', + 'sp.dealHint.PROFIT_SHARE': 'ФОТ клинеру и налог платим мы; партнёру — его % от того, что осталось: оплачено − ФОТ − налог.', + 'sp.commissionPct': 'Наш %, с оплаченного заказа', + 'sp.profitSharePct': '% партнёра от прибыли', + 'sp.fotPct': 'ФОТ клинера, % от заказа', + 'sp.taxPct': 'Налог, %', + 'sp.defaultHint': 'пусто — из настроек ({n}%)', + 'sp.exampleCOMMISSION': 'Пример: заказ оплачен на {sum} → нам {ours}, партнёру {partner}.', + 'sp.examplePROFIT_SHARE': 'Пример: оплачено {sum} − ФОТ {fot} − налог {tax} = {profit}; партнёру {partner}, нам {ours}.', + 'sp.activeLabel': 'Партнёр работает (можно назначать заказы)', + 'nav.points': 'Барики', + 'st.group.points': 'Барики (кэшбек)', + 'ag.hwPoints': 'Барики на новоселье', + 'ag.hwDays': 'Срок, дней', + 'ag.hwHint': '0 — не дарим. Начисляются один раз, сразу доступны.', + 'ag.hwValue': '{n} бариков на {days} дн.', + 'pt.title': 'Барики', + 'pt.tab.accrue': 'Начислить', + 'pt.tab.requests': 'Запросы', + 'pt.tab.suggestions': 'Предложено системой', + 'pt.tab.ledger': 'Журнал', + 'pt.tab.report': 'Отчёт', + 'pt.tab.import': 'Импорт CSV', + 'pt.tab.setup': 'Ставки и бонусы', + 'pt.search': 'Клиент', + 'pt.searchPh': 'Телефон, @username, имя или ID', + 'pt.notFound': 'Никого не нашли', + 'pt.pickClient': 'Найдите клиента слева — здесь появится форма начисления.', + 'pt.flagged': 'проверить: много отмен', + 'pt.balance': 'Доступно', + 'pt.pending': 'В ожидании', + 'pt.availableFrom': 'с {date}', + 'pt.tier': 'Уровень', + 'pt.burnAt': 'Сгорят', + 'pt.agency': 'Агентство', + 'pt.streak': 'Серия уборок', + 'pt.debt': 'Долг', + 'pt.redeem': 'Списать', + 'pt.redeemBtn': 'Списать', + 'pt.redeemed': 'Списано', + 'pt.allOps': 'Все операции', + 'pt.noOps': 'Операций пока нет.', + 'pt.reverse': 'Отменить', + 'pt.accrueTitle': 'Начисление', + 'pt.kind.ORDER': 'За заказ', + 'pt.kind.BONUS': 'Бонус', + 'pt.f.category': 'Категория', + 'pt.f.orderAmount': 'Сумма заказа, ֏', + 'pt.f.excluded': 'Не облагается, ֏', + 'pt.f.excludedHint': 'пошлины, материалы, чаевые', + 'pt.f.paidWithPoints': 'Оплачено бариками', + 'pt.f.serviceDate': 'Дата услуги', + 'pt.f.orderRef': 'Номер заказа', + 'pt.f.orderRefHint': 'второй раз по тому же номеру начислить нельзя', + 'pt.f.bonusType': 'Тип бонуса', + 'pt.f.ttl': 'Срок жизни, дней', + 'pt.f.ttlDefault': 'по умолчанию {n}', + 'pt.f.ttlNone': 'пусто — общее правило сгорания', + 'pt.f.amount': 'Сумма бариков', + 'pt.f.amountHint': 'подсказка: {n}', + 'pt.f.reason': 'Причина', + 'pt.f.reasonRequired': 'Причина (обязательно)', + 'pt.f.comment': 'Комментарий для клиента', + 'pt.f.commentPh': 'Увидит в истории', + 'pt.f.noWait': 'Без ожидания — сразу доступны', + 'pt.f.noWaitReason': 'Без ожидания (нужна причина)', + 'pt.f.redeemAmount': 'Сколько списать', + 'pt.f.defaultAmount': 'Сумма по умолчанию', + 'pt.formula': '{tier}: {rate}%{extra} → {n} бариков', + 'pt.accrueBtn': 'Начислить', + 'pt.confirmTitle': 'Проверьте', + 'pt.edit': 'Изменить', + 'pt.confirmOrder': 'Начислить {n} бариков {name} за {what} {sum} ֏ ({tier}, {rate}%). Станут доступны {date}.', + 'pt.confirmBonus': 'Начислить {n} бариков {name}: {what}, {until}.', + 'pt.now': 'сразу', + 'pt.forDays': 'на {n} дн.', + 'pt.noTtl': 'без своего срока', + 'pt.changedNote': 'Сумма изменена против подсказки ({n}) — попадёт в журнал с вашей причиной.', + 'pt.accrued': 'Начислено {n}', + 'pt.needsApproval': 'Выше лимита — отправлено суперадмину на подтверждение', + 'pt.zero': 'Сумма 0 — начислять нечего', + 'pt.reverseAccrual': 'Отменить начисление', + 'pt.reverseRedemption': 'Отменить списание', + 'pt.reverseAccrualText': 'Начисление {n} бариков будет отменено. Исходная запись останется в журнале.', + 'pt.reverseRedemptionText': '{n} бариков вернутся в исходные начисления с их сроками.', + 'pt.debtWarn': 'Из начисления уже потрачено {spent} — баланс уйдёт в минус: {after}. Минус погасится следующими начислениями.', + 'pt.reasonPh': 'Возврат денег, ошибка…', + 'pt.reverseHint': 'Поддержка отменяет только свои операции и только в течение 24 часов.', + 'pt.reversed': 'Отменено', + 'pt.maxHint': 'максимум {max} ({share}% заказа и не больше баланса), минимум {min}', + 'pt.redeemReasonPh': 'По звонку, заказ вне приложения…', + 'pt.toPay': 'К доплате: {sum} ֏', + 'pt.noRequests': 'Запросов нет.', + 'pt.wants': 'хочет списать {n}', + 'pt.forOrder': 'заказ №{n}', + 'pt.noOrder': 'заказ вне приложения', + 'pt.balanceN': 'баланс {n}', + 'pt.confirm': 'Подтвердить', + 'pt.confirmN': 'Списать {n}', + 'pt.confirmed': 'Списано {n}', + 'pt.reject': 'Отклонить', + 'pt.rejectReason': 'Причина — её увидит клиент', + 'pt.noSuggestions': 'Предложений нет.', + 'pt.suggestionsHint': 'Система нашла, кому положен бонус. Начислите, измените сумму или отклоните.', + 'pt.mech.first_month_route': 'Маршрут первого месяца', + 'pt.mech.photo_review': 'Отзыв с фото', + 'pt.mech.over_limit': 'Выше лимита поддержки', + 'pt.why.first_month_route': '3 разные категории за первые 30 дней', + 'pt.why.photo_review': 'прислал фото после заказа №{n}', + 'pt.why.over_limit': '{by}: {op}', + 'pt.op.accrual': 'начисление', + 'pt.op.redemption': 'списание', + 'pt.approve': 'Подтвердить', + 'pt.changeAmount': 'Изменить сумму', + 'pt.allTypes': 'Все типы', + 'pt.allCategories': 'Все категории', + 'pt.allChannels': 'Все каналы', + 'pt.allClients': 'Все клиенты', + 'pt.type.accrual': 'Начисление', + 'pt.type.bonus': 'Бонус', + 'pt.type.redemption': 'Списание', + 'pt.type.request': 'Запрос клиента', + 'pt.type.reversal': 'Отмена', + 'pt.channel.WEB': 'админка', + 'pt.channel.BOT': 'бот', + 'pt.channel.CSV': 'CSV', + 'pt.channel.AUTO': 'авто', + 'pt.channel.CLIENT': 'клиент', + 'pt.status.PENDING': 'в ожидании', + 'pt.status.ACTIVE': 'активно', + 'pt.status.SPENT': 'потрачено', + 'pt.status.EXPIRED': 'сгорело', + 'pt.status.REVERSED': 'отменено', + 'pt.status.REQUESTED': 'ждёт', + 'pt.status.CONFIRMED': 'списано', + 'pt.status.REJECTED': 'отклонено', + 'pt.status.ACCRUAL': 'начисления', + 'pt.status.REDEMPTION': 'списания', + 'pt.col.date': 'Когда', + 'pt.col.client': 'Клиент', + 'pt.col.what': 'Что', + 'pt.col.amount': 'Барики', + 'pt.col.status': 'Статус', + 'pt.col.author': 'Кто', + 'pt.system': 'система', + 'pt.suggestedN': 'подсказка {n}', + 'pt.debtN': 'в долг {n}', + 'pt.rep.accrued': 'Начислено', + 'pt.rep.spent': 'Потрачено', + 'pt.rep.expired': 'Сгорело', + 'pt.rep.reversed': 'Отменено', + 'pt.rep.liabilities': 'Обязательства сейчас', + 'pt.rep.rate': 'Доля потраченных', + 'pt.rep.byCategory': 'Стоимость программы по категориям', + 'pt.rep.cost': '{pct}% от выручки {revenue} ֏', + 'pt.rep.retention': 'Повторный заказ за 30 дней', + 'pt.rep.with': 'С бариками', + 'pt.rep.without': 'Без бариков', + 'pt.rep.clientsN': 'клиентов {n}, с 2+ категориями {multi}', + 'pt.rep.topClients': 'Топ по балансу', + 'pt.rep.topAgencies': 'Агентства: активации новоселья', + 'pt.importHint': 'CSV: телефон, категория (cleaning, documents, learning, repair, transfer), сумма заказа, дата (ГГГГ-ММ-ДД), номер заказа. Сначала — предпросмотр, начисление только после подтверждения.', + 'pt.importRows': 'Строк', + 'pt.importTotal': 'Бариков всего', + 'pt.importErr.client': 'клиент не найден', + 'pt.importErr.category': 'нет такой категории', + 'pt.importErr.date': 'неверная дата', + 'pt.importErr.duplicate': 'дубль заказа', + 'pt.importCommit': 'Начислить {n} строк', + 'pt.importDone': 'Начислено по {n} строкам', + 'pt.categories': 'Категории и ставки', + 'pt.bonusTypes': 'Типы бонусов', + 'pt.code': 'Код', + 'pt.activeLabel': 'Работает', + 'pt.rulesHint': 'Доступны через {hours} ч после услуги · оплатить можно до {share}% заказа, минимум {min} · сгорают через {burn} дней без активности. Меняется в «Настройках».', + 'err.POINTS_DUPLICATE_ORDER': 'За этот заказ барики уже начислены.', + 'err.POINTS_REASON_REQUIRED': 'Укажите причину.', + 'err.POINTS_BELOW_MIN': 'Меньше минимальной суммы списания.', + 'err.POINTS_NOT_ENOUGH': 'Не хватает бариков.', + 'err.POINTS_OVER_SHARE': 'Больше допустимой доли заказа.', + 'err.POINTS_OVER_LIMIT': 'Выше вашего лимита — нужно подтверждение суперадмина.', + 'err.POINTS_FINAL': 'Эту операцию уже нельзя отменить.', + 'err.POINTS_REQUEST_CLOSED': 'Запрос уже обработан или истёк.', + 'err.POINTS_WITH_PROMO': 'Барики нельзя вместе с промокодом.', + 'pt.formulaStreak': ' + серия уборок {streak}: +{bonus}%', + 'ord.editComposition': 'Изменить состав', + 'ord.editTitle': 'Состав заказа №{n}', + 'ord.editPrice': 'Цена: было {was} → станет {now} (скидка {pct}%, как при оформлении)', + 'ord.editDuration': 'Длительность: {was} → {now} мин', + 'ord.editBusy': 'Заказ стал дольше, а у исполнителя в это время другой заказ — проверьте график.', + 'ord.editPaid': 'Заказ уже оплачен: пересчитаем вознаграждение агентства, доход партнёра и барики. Разницу в деньгах решите с клиентом.', + 'ord.editOwnPrice': 'Своя итоговая цена, ֏', + 'ord.editOwnPriceHint': 'пусто — по прайсу: {n}', + 'ord.editReason': 'Причина', + 'ord.editReasonRequired': 'Причина (обязательно для своей цены)', + 'ord.editReasonPh': 'На месте добавили окна…', + 'ord.editNotify': 'Сообщить клиенту и исполнителю', + 'ord.edited': 'Состав заказа изменён', + 'ord.editedBusy': 'Изменено. Внимание: исполнитель может не успеть — проверьте график', + 'ord.uncheck': 'Снять отметку', + 'ord.checkForProvider': 'Отметить за исполнителя', + 'ord.noPhotoReason': 'без фото: {reason}', + 'ord.byStaff': 'отметила команда', + 'ord.checkNoPhoto': 'Отметить без фото', + 'ord.photoHint': 'Для этого пункта нужно фото. Загрузите его или отметьте без фото с причиной.', + 'ord.uploadPhoto': 'Загрузить фото', + 'ord.noPhotoField': 'Или причина, почему без фото', + 'ord.noPhotoPh': 'Клинер прислал фото в чат…', + 'err.PHOTO_REQUIRED': 'Нужно фото или причина, почему без него.', }; export type BoKey = keyof typeof ru; @@ -1211,8 +1484,8 @@ const en: Dict = { 'nav.chats': 'Chats', 'nav.orders': 'Orders', 'nav.providers': 'Providers', - 'nav.agencies': 'Agencies', - 'nav.venues': 'Venues', + 'nav.agencies': 'Real estate agencies', + 'nav.venues': 'Club partners', 'nav.crm': 'CRM', 'nav.products': 'Products', 'nav.journal': 'Activity log', @@ -1824,7 +2097,7 @@ const en: Dict = { 'st.saveN': 'Save ({n})', 'st.group.orders': 'Orders', 'st.group.subs': 'Membership', - 'st.group.agencies': 'Agencies', + 'st.group.agencies': 'Real estate agencies', 'st.group.sales': 'Sales bonus', 'st.group.site': 'Website prices', 'st.group.other': 'Other', @@ -1977,22 +2250,22 @@ const en: Dict = { 'venue.cat.shop': 'Shop', 'venue.cat.beauty': 'Beauty', 'venue.cat.other': 'Other', - 'ag.title': 'Agencies', - 'ag.add': 'New agency', - 'ag.editTitle': 'Agency', - 'ag.empty': 'No agencies yet.', - 'ag.col.name': 'Agency', + 'ag.title': 'Real estate agencies', + 'ag.add': 'New real estate agency', + 'ag.editTitle': 'Real estate agency', + 'ag.empty': 'No real estate agencies yet.', + 'ag.col.name': 'Real estate agency', 'ag.col.clients': 'Clients', 'ag.col.gifts': 'Gifts', 'ag.col.month': 'Reward, month', 'ag.col.balance': 'To pay', 'ag.hint': 'Reward = % of paid orders of the agency’s clients while they’re attached.', 'ag.inactive': 'inactive', - 'ag.back': 'All agencies', + 'ag.back': 'All real estate agencies', 'ag.pct': '{n}% of client orders', 'ag.byS': 'connected by {name}', 'ag.link': 'Client link', - 'ag.linkHint': 'The client opens the bot, gets a gift month and is attached to the agency. Or types code {code} via /code.', + 'ag.linkHint': 'The client opens the bot, gets the agency gift and is attached to the agency. Or types code {code} via /code.', 'ag.clients': 'Clients', 'ag.giftsN': 'gifts: {n}', 'ag.noClients': 'No clients yet.', @@ -2006,7 +2279,7 @@ const en: Dict = { 'ag.sales': 'Connected by', 'ag.contactPh': 'Name, phone, Telegram', 'ag.active': 'Agency active (code accepted)', - 'vn.title': 'Venues', + 'vn.title': 'Club partners', 'vn.add': 'New venue', 'vn.editTitle': 'Venue', 'vn.empty': 'No venues yet.', @@ -2337,6 +2610,279 @@ const en: Dict = { 'audit.bo.chats.openFor': 'opened a chat with a client', 'audit.bo.chats.templates.save': 'edited a reply template', 'audit.bo.chats.templates.remove': 'deleted a reply template', + 'nav.servicePartners': 'Service partners', + 'st.group.partners': 'Service partners', + 'ag.giftPlan': 'Gift for new clients', + 'ag.giftMonths': 'For how long', + 'ag.giftHint': 'Applies to new clients; those who already got a gift keep theirs.', + 'ag.noGift': 'no gift', + 'ag.giftValue': '{plan} for {n} mo', + 'ag.monthsN': '{n} mo', + 'ord.partner': 'partner', + 'ord.best': 'best for us', + 'ord.partnerFree': '{n} of {of} free', + 'ord.candMoney': 'We pay {cost} · we keep {margin}', + 'prv.ratePartnerPays': 'Paid by the partner', + 'rq.missing': 'missing details', + 'rq.noTime': 'not given', + 'rq.noDistrict': 'no district', + 'rq.noAddress': 'no address', + 'sp.title': 'Service partners', + 'sp.add': 'New partner', + 'sp.editTitle': 'Service partner', + 'sp.lead': 'Cleaning companies with their own staff. The partner’s manager gets a link, adds staff and assigns orders. An operator gives an order to a partner from the order card.', + 'sp.empty': 'No partners yet.', + 'sp.col.name': 'Partner', + 'sp.col.staff': 'Staff', + 'sp.col.active': 'Active orders', + 'sp.col.month': 'Partner income, month', + 'sp.col.balance': 'To pay', + 'sp.noManager': 'no manager', + 'sp.auto': 'auto-assign', + 'sp.back': 'All service partners', + 'sp.terms.COMMISSION': 'Uber-style: we take {pct}%', + 'sp.terms.PROFIT_SHARE': 'our costs: payroll {fot}%, tax {tax}%, partner gets {pct}% of profit', + 'sp.managerLink': 'Manager link', + 'sp.managerLinkHint': 'Give this link to the partner’s manager: they open the work bot and get the cabinet — orders, staff, auto-assignment, income. It also opens on a computer (button in the bot).', + 'sp.resetLink': 'New link', + 'sp.resetConfirm': 'Make a new link? The old one stops working; connected managers stay.', + 'sp.linkReset': 'Link updated', + 'sp.managers': 'Managers', + 'sp.noManagers': 'No manager has joined via the link yet.', + 'sp.managerSince': 'since {date}', + 'sp.remove': 'Remove', + 'sp.removeManagerConfirm': 'Remove {name} from the partner’s managers?', + 'sp.staff': 'Staff', + 'sp.staffHint': 'added by the partner’s manager', + 'sp.noStaff': 'No staff yet.', + 'sp.limitOrders': 'up to {n} orders a day', + 'sp.limitHours': 'up to {n} h a day', + 'sp.notLinked': 'not in the bot', + 'sp.off': 'off', + 'sp.orders': 'Active orders', + 'sp.noOrders': 'The partner has no orders right now.', + 'sp.waitingStaff': 'waiting for staff', + 'sp.toPay': 'Partner income to pay', + 'sp.byOrder': 'Income by order', + 'sp.noIncome': 'Income appears once a client pays for a completed order.', + 'sp.paid': 'paid {sum}', + 'sp.fotTax': 'payroll {fot}, tax {tax}', + 'sp.payoutTitle': 'Payout to partner', + 'sp.phone': 'Phone', + 'sp.service': 'Service', + 'sp.cleaningOnly': 'Cleaning (only this for now)', + 'sp.deal': 'Deal model', + 'sp.deal.COMMISSION': 'Uber-style: our % of order', + 'sp.deal.PROFIT_SHARE': 'Our costs: % to partner', + 'sp.dealHint.COMMISSION': 'We keep our % of each paid order, the rest goes to the partner. Wages and costs are theirs.', + 'sp.dealHint.PROFIT_SHARE': 'We pay the cleaner and the tax; the partner gets their % of what’s left: paid − payroll − tax.', + 'sp.commissionPct': 'Our %, of paid order', + 'sp.profitSharePct': 'Partner’s % of profit', + 'sp.fotPct': 'Cleaner payroll, % of order', + 'sp.taxPct': 'Tax, %', + 'sp.defaultHint': 'empty — from settings ({n}%)', + 'sp.exampleCOMMISSION': 'Example: order paid {sum} → we keep {ours}, partner gets {partner}.', + 'sp.examplePROFIT_SHARE': 'Example: paid {sum} − payroll {fot} − tax {tax} = {profit}; partner {partner}, us {ours}.', + 'sp.activeLabel': 'Partner active (orders can be assigned)', + 'nav.points': 'Bariki', + 'st.group.points': 'Bariki (cashback)', + 'ag.hwPoints': 'Housewarming bariki', + 'ag.hwDays': 'Valid, days', + 'ag.hwHint': '0 — none. Credited once, available right away.', + 'ag.hwValue': '{n} bariki for {days} days', + 'pt.title': 'Bariki', + 'pt.tab.accrue': 'Credit', + 'pt.tab.requests': 'Requests', + 'pt.tab.suggestions': 'Suggested', + 'pt.tab.ledger': 'Log', + 'pt.tab.report': 'Report', + 'pt.tab.import': 'CSV import', + 'pt.tab.setup': 'Rates & bonuses', + 'pt.search': 'Client', + 'pt.searchPh': 'Phone, @username, name or ID', + 'pt.notFound': 'No one found', + 'pt.pickClient': 'Find a client on the left — the credit form appears here.', + 'pt.flagged': 'check: many reversals', + 'pt.balance': 'Available', + 'pt.pending': 'Pending', + 'pt.availableFrom': 'from {date}', + 'pt.tier': 'Level', + 'pt.burnAt': 'Expire on', + 'pt.agency': 'Agency', + 'pt.streak': 'Cleaning streak', + 'pt.debt': 'Debt', + 'pt.redeem': 'Redeem', + 'pt.redeemBtn': 'Redeem', + 'pt.redeemed': 'Redeemed', + 'pt.allOps': 'All operations', + 'pt.noOps': 'No operations yet.', + 'pt.reverse': 'Reverse', + 'pt.accrueTitle': 'Credit', + 'pt.kind.ORDER': 'For an order', + 'pt.kind.BONUS': 'Bonus', + 'pt.f.category': 'Category', + 'pt.f.orderAmount': 'Order amount, ֏', + 'pt.f.excluded': 'Not eligible, ֏', + 'pt.f.excludedHint': 'fees, materials, tips', + 'pt.f.paidWithPoints': 'Paid with bariki', + 'pt.f.serviceDate': 'Service date', + 'pt.f.orderRef': 'Order number', + 'pt.f.orderRefHint': 'the same number can’t be credited twice', + 'pt.f.bonusType': 'Bonus type', + 'pt.f.ttl': 'Valid for, days', + 'pt.f.ttlDefault': 'default {n}', + 'pt.f.ttlNone': 'empty — general expiry rule', + 'pt.f.amount': 'Bariki amount', + 'pt.f.amountHint': 'suggested: {n}', + 'pt.f.reason': 'Reason', + 'pt.f.reasonRequired': 'Reason (required)', + 'pt.f.comment': 'Comment for the client', + 'pt.f.commentPh': 'Shown in their history', + 'pt.f.noWait': 'No waiting — available now', + 'pt.f.noWaitReason': 'No waiting (reason required)', + 'pt.f.redeemAmount': 'How much to redeem', + 'pt.f.defaultAmount': 'Default amount', + 'pt.formula': '{tier}: {rate}%{extra} → {n} bariki', + 'pt.accrueBtn': 'Credit', + 'pt.confirmTitle': 'Please check', + 'pt.edit': 'Edit', + 'pt.confirmOrder': 'Credit {n} bariki to {name} for {what} {sum} ֏ ({tier}, {rate}%). Available {date}.', + 'pt.confirmBonus': 'Credit {n} bariki to {name}: {what}, {until}.', + 'pt.now': 'right away', + 'pt.forDays': 'for {n} days', + 'pt.noTtl': 'no own expiry', + 'pt.changedNote': 'Amount differs from the suggestion ({n}) — logged with your reason.', + 'pt.accrued': 'Credited {n}', + 'pt.needsApproval': 'Above the limit — sent to a super admin', + 'pt.zero': 'Amount is 0 — nothing to credit', + 'pt.reverseAccrual': 'Reverse credit', + 'pt.reverseRedemption': 'Reverse redemption', + 'pt.reverseAccrualText': 'The credit of {n} bariki will be reversed. The original stays in the log.', + 'pt.reverseRedemptionText': '{n} bariki return to the original credits with their expiry.', + 'pt.debtWarn': '{spent} of this credit is already spent — balance goes negative: {after}. Future credits will cover it.', + 'pt.reasonPh': 'Refund, mistake…', + 'pt.reverseHint': 'Support can reverse only their own operations within 24 hours.', + 'pt.reversed': 'Reversed', + 'pt.maxHint': 'max {max} ({share}% of the order, within balance), min {min}', + 'pt.redeemReasonPh': 'By phone, order outside the app…', + 'pt.toPay': 'Left to pay: {sum} ֏', + 'pt.noRequests': 'No requests.', + 'pt.wants': 'wants to use {n}', + 'pt.forOrder': 'order #{n}', + 'pt.noOrder': 'order outside the app', + 'pt.balanceN': 'balance {n}', + 'pt.confirm': 'Confirm', + 'pt.confirmN': 'Redeem {n}', + 'pt.confirmed': 'Redeemed {n}', + 'pt.reject': 'Decline', + 'pt.rejectReason': 'Reason — the client will see it', + 'pt.noSuggestions': 'No suggestions.', + 'pt.suggestionsHint': 'The system found who qualifies for a bonus. Credit, change the amount or decline.', + 'pt.mech.first_month_route': 'First-month route', + 'pt.mech.photo_review': 'Photo review', + 'pt.mech.over_limit': 'Above support limit', + 'pt.why.first_month_route': '3 different categories in the first 30 days', + 'pt.why.photo_review': 'sent a photo after order #{n}', + 'pt.why.over_limit': '{by}: {op}', + 'pt.op.accrual': 'credit', + 'pt.op.redemption': 'redemption', + 'pt.approve': 'Approve', + 'pt.changeAmount': 'Change amount', + 'pt.allTypes': 'All types', + 'pt.allCategories': 'All categories', + 'pt.allChannels': 'All channels', + 'pt.allClients': 'All clients', + 'pt.type.accrual': 'Credit', + 'pt.type.bonus': 'Bonus', + 'pt.type.redemption': 'Redemption', + 'pt.type.request': 'Client request', + 'pt.type.reversal': 'Reversal', + 'pt.channel.WEB': 'admin', + 'pt.channel.BOT': 'bot', + 'pt.channel.CSV': 'CSV', + 'pt.channel.AUTO': 'auto', + 'pt.channel.CLIENT': 'client', + 'pt.status.PENDING': 'pending', + 'pt.status.ACTIVE': 'active', + 'pt.status.SPENT': 'spent', + 'pt.status.EXPIRED': 'expired', + 'pt.status.REVERSED': 'reversed', + 'pt.status.REQUESTED': 'waiting', + 'pt.status.CONFIRMED': 'redeemed', + 'pt.status.REJECTED': 'declined', + 'pt.status.ACCRUAL': 'of a credit', + 'pt.status.REDEMPTION': 'of a redemption', + 'pt.col.date': 'When', + 'pt.col.client': 'Client', + 'pt.col.what': 'What', + 'pt.col.amount': 'Bariki', + 'pt.col.status': 'Status', + 'pt.col.author': 'Who', + 'pt.system': 'system', + 'pt.suggestedN': 'suggested {n}', + 'pt.debtN': 'debt {n}', + 'pt.rep.accrued': 'Credited', + 'pt.rep.spent': 'Spent', + 'pt.rep.expired': 'Expired', + 'pt.rep.reversed': 'Reversed', + 'pt.rep.liabilities': 'Outstanding now', + 'pt.rep.rate': 'Redemption rate', + 'pt.rep.byCategory': 'Programme cost by category', + 'pt.rep.cost': '{pct}% of {revenue} ֏ revenue', + 'pt.rep.retention': 'Repeat order within 30 days', + 'pt.rep.with': 'With bariki', + 'pt.rep.without': 'Without bariki', + 'pt.rep.clientsN': '{n} clients, {multi} with 2+ categories', + 'pt.rep.topClients': 'Top by balance', + 'pt.rep.topAgencies': 'Agencies: housewarming activations', + 'pt.importHint': 'CSV: phone, category (cleaning, documents, learning, repair, transfer), order amount, date (YYYY-MM-DD), order number. Preview first; credited only after you confirm.', + 'pt.importRows': 'Rows', + 'pt.importTotal': 'Total bariki', + 'pt.importErr.client': 'client not found', + 'pt.importErr.category': 'unknown category', + 'pt.importErr.date': 'bad date', + 'pt.importErr.duplicate': 'duplicate order', + 'pt.importCommit': 'Credit {n} rows', + 'pt.importDone': 'Credited {n} rows', + 'pt.categories': 'Categories and rates', + 'pt.bonusTypes': 'Bonus types', + 'pt.code': 'Code', + 'pt.activeLabel': 'Active', + 'pt.rulesHint': 'Available {hours} h after the service · up to {share}% of an order, min {min} · expire after {burn} days of inactivity. Change in Settings.', + 'err.POINTS_DUPLICATE_ORDER': 'Bariki for this order were already credited.', + 'err.POINTS_REASON_REQUIRED': 'Please give a reason.', + 'err.POINTS_BELOW_MIN': 'Below the minimum redemption.', + 'err.POINTS_NOT_ENOUGH': 'Not enough bariki.', + 'err.POINTS_OVER_SHARE': 'More than the allowed share of the order.', + 'err.POINTS_OVER_LIMIT': 'Above your limit — needs a super admin.', + 'err.POINTS_FINAL': 'This can no longer be reversed.', + 'err.POINTS_REQUEST_CLOSED': 'Request already handled or expired.', + 'err.POINTS_WITH_PROMO': 'Bariki can’t be combined with a promo code.', + 'pt.formulaStreak': ' + cleaning streak {streak}: +{bonus}%', + 'ord.editComposition': 'Edit composition', + 'ord.editTitle': 'Order #{n} composition', + 'ord.editPrice': 'Price: was {was} → now {now} ({pct}% discount, as when booked)', + 'ord.editDuration': 'Duration: {was} → {now} min', + 'ord.editBusy': 'The order got longer and the provider has another job then — check the schedule.', + 'ord.editPaid': 'Already paid: agency reward, partner income and bariki will be recalculated. Settle the money difference with the client.', + 'ord.editOwnPrice': 'Custom total, ֏', + 'ord.editOwnPriceHint': 'empty — price list: {n}', + 'ord.editReason': 'Reason', + 'ord.editReasonRequired': 'Reason (required for a custom price)', + 'ord.editReasonPh': 'Added windows on site…', + 'ord.editNotify': 'Notify the client and the provider', + 'ord.edited': 'Order updated', + 'ord.editedBusy': 'Updated. The provider may not have time — check the schedule', + 'ord.uncheck': 'Uncheck', + 'ord.checkForProvider': 'Check for the provider', + 'ord.noPhotoReason': 'no photo: {reason}', + 'ord.byStaff': 'checked by the team', + 'ord.checkNoPhoto': 'Check without photo', + 'ord.photoHint': 'This item needs a photo. Upload one or check it without a photo and a reason.', + 'ord.uploadPhoto': 'Upload photo', + 'ord.noPhotoField': 'Or why there is no photo', + 'ord.noPhotoPh': 'The cleaner sent it in the chat…', + 'err.PHOTO_REQUIRED': 'A photo or a reason is required.', }; const hy: Dict = { @@ -2376,8 +2922,8 @@ const hy: Dict = { 'nav.chats': 'Չատեր', 'nav.orders': 'Պատվերներ', 'nav.providers': 'Կատարողներ', - 'nav.agencies': 'Գործակալություններ', - 'nav.venues': 'Հաստատություններ', + 'nav.agencies': 'Անշարժ գույքի գործակալություններ', + 'nav.venues': 'Ակումբի գործընկերներ', 'nav.crm': 'CRM', 'nav.products': 'Ապրանքներ', 'nav.journal': 'Մատյան', @@ -2989,7 +3535,7 @@ const hy: Dict = { 'st.saveN': 'Պահպանել ({n})', 'st.group.orders': 'Պատվերներ', 'st.group.subs': 'Բաժանորդագրություն', - 'st.group.agencies': 'Գործակալություններ', + 'st.group.agencies': 'Անշարժ գույքի գործակալություններ', 'st.group.sales': 'Վաճառողների բոնուս', 'st.group.site': 'Կայքի գներ', 'st.group.other': 'Այլ', @@ -3142,10 +3688,10 @@ const hy: Dict = { 'venue.cat.shop': 'Խանութ', 'venue.cat.beauty': 'Գեղեցկություն', 'venue.cat.other': 'Այլ', - 'ag.title': 'Գործակալություններ', - 'ag.add': 'Նոր գործակալություն', - 'ag.editTitle': 'Գործակալություն', - 'ag.empty': 'Գործակալություններ դեռ չկան։', + 'ag.title': 'Անշարժ գույքի գործակալություններ', + 'ag.add': 'Նոր անշարժ գույքի գործակալություն', + 'ag.editTitle': 'Անշարժ գույքի գործակալություն', + 'ag.empty': 'Անշարժ գույքի գործակալություններ դեռ չկան։', 'ag.col.name': 'Գործակալություն', 'ag.col.clients': 'Հաճախորդներ', 'ag.col.gifts': 'Նվերներ', @@ -3157,7 +3703,7 @@ const hy: Dict = { 'ag.pct': '{n}% հաճախորդների պատվերներից', 'ag.byS': 'միացրել է {name}', 'ag.link': 'Հղում հաճախորդների համար', - 'ag.linkHint': 'Հաճախորդը բացում է բոտը և ստանում նվեր ամիս։ Կամ մուտքագրում է {code} կոդը։', + 'ag.linkHint': 'Հաճախորդը բացում է բոտը, ստանում գործակալության նվերը և կցվում նրան։ Կամ մուտքագրում է {code} կոդը /code հրամանով։', 'ag.clients': 'Հաճախորդներ', 'ag.giftsN': 'նվերներ՝ {n}', 'ag.noClients': 'Հաճախորդներ դեռ չկան։', @@ -3170,8 +3716,8 @@ const hy: Dict = { 'ag.pctLabel': 'Պարգև, %', 'ag.sales': 'Միացրել է', 'ag.contactPh': 'Անուն, հեռախոս, Telegram', - 'ag.active': 'Գործակալությունն աշխատում է', - 'vn.title': 'Հաստատություններ', + 'ag.active': 'Գործակալությունն աշխատում է (կոդն ընդունվում է)', + 'vn.title': 'Ակումբի գործընկերներ', 'vn.add': 'Նոր հաստատություն', 'vn.editTitle': 'Հաստատություն', 'vn.empty': 'Հաստատություններ դեռ չկան։', @@ -3502,6 +4048,279 @@ const hy: Dict = { 'audit.bo.chats.openFor': 'բացեց զրույց հաճախորդի հետ', 'audit.bo.chats.templates.save': 'փոխեց պատասխանի ձևանմուշը', 'audit.bo.chats.templates.remove': 'ջնջեց պատասխանի ձևանմուշը', + 'nav.servicePartners': 'Ծառայությունների գործընկերներ', + 'st.group.partners': 'Ծառայությունների գործընկերներ', + 'ag.giftPlan': 'Նվեր նոր հաճախորդներին', + 'ag.giftMonths': 'Որքան ժամանակով', + 'ag.giftHint': 'Գործում է նոր հաճախորդների համար․ արդեն նվեր ստացածների մոտ ոչինչ չի փոխվում։', + 'ag.noGift': 'առանց նվերի', + 'ag.giftValue': '{plan}՝ {n} ամիս', + 'ag.monthsN': '{n} ամիս', + 'ord.partner': 'գործընկեր', + 'ord.best': 'ամենաշահավետը', + 'ord.partnerFree': 'ազատ է {n}-ը {of}-ից', + 'ord.candMoney': 'Վճարում ենք {cost} · մեզ մնում է {margin}', + 'prv.ratePartnerPays': 'Աշխատավարձը վճարում է գործընկերը', + 'rq.missing': 'տվյալներ չեն բավարարում', + 'rq.noTime': 'նշված չէ', + 'rq.noDistrict': 'շրջանը նշված չէ', + 'rq.noAddress': 'հասցեն նշված չէ', + 'sp.title': 'Ծառայությունների գործընկերներ', + 'sp.add': 'Նոր գործընկեր', + 'sp.editTitle': 'Ծառայությունների գործընկեր', + 'sp.lead': 'Մաքրման ընկերություններ՝ իրենց աշխատակիցներով։ Գործընկերոջ մենեջերը ստանում է հղում, ինքն է ավելացնում աշխատակիցներին և բաշխում պատվերները։ Պատվերը գործընկերոջը նշանակում է օպերատորը։', + 'sp.empty': 'Գործընկերներ դեռ չկան։', + 'sp.col.name': 'Գործընկեր', + 'sp.col.staff': 'Աշխատակիցներ', + 'sp.col.active': 'Ընթացիկ պատվերներ', + 'sp.col.month': 'Գործընկերոջ եկամուտը, ամիս', + 'sp.col.balance': 'Վճարման ենթակա', + 'sp.noManager': 'մենեջեր չկա', + 'sp.auto': 'ավտոբաշխում', + 'sp.back': 'Բոլոր գործընկերները', + 'sp.terms.COMMISSION': 'Uber-ի պես՝ մեր {pct}%-ը', + 'sp.terms.PROFIT_SHARE': 'ծախսերը մերն են՝ աշխ. {fot}%, հարկ {tax}%, գործընկերոջը՝ շահույթի {pct}%', + 'sp.managerLink': 'Մենեջերի հղումը', + 'sp.managerLinkHint': 'Տվեք հղումը գործընկերոջ մենեջերին․ նա կբացի աշխատանքային բոտը և կստանա կաբինետ՝ պատվերներ, աշխատակիցներ, ավտոբաշխում, եկամուտ։ Կաբինետը բացվում է նաև համակարգչում (կոճակը բոտում)։', + 'sp.resetLink': 'Նոր հղում', + 'sp.resetConfirm': 'Ստեղծե՞լ նոր հղում։ Հինը կդադարի աշխատել, միացված մենեջերները կմնան։', + 'sp.linkReset': 'Հղումը թարմացված է', + 'sp.managers': 'Մենեջերներ', + 'sp.noManagers': 'Մենեջերը դեռ չի միացել հղումով։', + 'sp.managerSince': '{date}-ից', + 'sp.remove': 'Հեռացնել', + 'sp.removeManagerConfirm': 'Հեռացնե՞լ {name}-ին մենեջերներից։', + 'sp.staff': 'Աշխատակիցներ', + 'sp.staffHint': 'ավելացնում է գործընկերոջ մենեջերը', + 'sp.noStaff': 'Աշխատակիցներ դեռ չկան։', + 'sp.limitOrders': 'օրական մինչև {n} պատվեր', + 'sp.limitHours': 'օրական մինչև {n} ժ', + 'sp.notLinked': 'բոտ չի մտել', + 'sp.off': 'անջատված', + 'sp.orders': 'Ընթացիկ պատվերներ', + 'sp.noOrders': 'Գործընկերը հիմա պատվեր չունի։', + 'sp.waitingStaff': 'սպասում է աշխատակցի', + 'sp.toPay': 'Գործընկերոջ եկամուտը վճարման', + 'sp.byOrder': 'Եկամուտն ըստ պատվերների', + 'sp.noIncome': 'Եկամուտը կհայտնվի, երբ հաճախորդը վճարի կատարված պատվերի համար։', + 'sp.paid': 'վճարված՝ {sum}', + 'sp.fotTax': 'աշխ. {fot}, հարկ {tax}', + 'sp.payoutTitle': 'Վճարում գործընկերոջը', + 'sp.phone': 'Հեռախոս', + 'sp.service': 'Ծառայություն', + 'sp.cleaningOnly': 'Մաքրում (առայժմ միայն այն)', + 'sp.deal': 'Համագործակցության մոդել', + 'sp.deal.COMMISSION': 'Uber-ի պես՝ մեր %-ը', + 'sp.deal.PROFIT_SHARE': 'Ծախսերը մերն են՝ % գործընկերոջը', + 'sp.dealHint.COMMISSION': 'Վճարված պատվերից վերցնում ենք մեր %-ը, մնացածը՝ գործընկերոջը։ Աշխատավարձն ու ծախսերը նրանն են։', + 'sp.dealHint.PROFIT_SHARE': 'Մաքրողի աշխատավարձը և հարկը վճարում ենք մենք․ գործընկերոջը՝ մնացորդի իր %-ը՝ վճարված − աշխ. − հարկ։', + 'sp.commissionPct': 'Մեր %-ը՝ վճարված պատվերից', + 'sp.profitSharePct': 'Գործընկերոջ %-ը շահույթից', + 'sp.fotPct': 'Մաքրողի աշխ., պատվերի %', + 'sp.taxPct': 'Հարկ, %', + 'sp.defaultHint': 'դատարկ՝ կարգավորումներից ({n}%)', + 'sp.exampleCOMMISSION': 'Օրինակ՝ վճարվել է {sum} → մեզ {ours}, գործընկերոջը {partner}։', + 'sp.examplePROFIT_SHARE': 'Օրինակ՝ վճարված {sum} − աշխ. {fot} − հարկ {tax} = {profit}․ գործընկերոջը {partner}, մեզ {ours}։', + 'sp.activeLabel': 'Գործընկերն աշխատում է (կարելի է նշանակել պատվերներ)', + 'nav.points': 'Բարիկներ', + 'st.group.points': 'Բարիկներ (քեշբեք)', + 'ag.hwPoints': 'Բարիկներ բնակարանամուտին', + 'ag.hwDays': 'Ժամկետ, օր', + 'ag.hwHint': '0՝ չենք նվիրում։ Մեկ անգամ, անմիջապես հասանելի։', + 'ag.hwValue': '{n} բարիկ {days} օրով', + 'pt.title': 'Բարիկներ', + 'pt.tab.accrue': 'Հաշվեգրել', + 'pt.tab.requests': 'Հայտեր', + 'pt.tab.suggestions': 'Համակարգի առաջարկներ', + 'pt.tab.ledger': 'Մատյան', + 'pt.tab.report': 'Հաշվետվություն', + 'pt.tab.import': 'CSV ներմուծում', + 'pt.tab.setup': 'Տոկոսներ և բոնուսներ', + 'pt.search': 'Հաճախորդ', + 'pt.searchPh': 'Հեռախոս, @username, անուն կամ ID', + 'pt.notFound': 'Ոչ ոք չգտնվեց', + 'pt.pickClient': 'Գտեք հաճախորդին ձախից․ այստեղ կհայտնվի ձևը։', + 'pt.flagged': 'ստուգել՝ շատ չեղարկումներ', + 'pt.balance': 'Հասանելի', + 'pt.pending': 'Սպասման մեջ', + 'pt.availableFrom': '{date}-ից', + 'pt.tier': 'Մակարդակ', + 'pt.burnAt': 'Կայրվեն', + 'pt.agency': 'Գործակալություն', + 'pt.streak': 'Մաքրումների շարք', + 'pt.debt': 'Պարտք', + 'pt.redeem': 'Դուրս գրել', + 'pt.redeemBtn': 'Դուրս գրել', + 'pt.redeemed': 'Դուրս է գրված', + 'pt.allOps': 'Բոլոր գործողությունները', + 'pt.noOps': 'Գործողություններ դեռ չկան։', + 'pt.reverse': 'Չեղարկել', + 'pt.accrueTitle': 'Հաշվեգրում', + 'pt.kind.ORDER': 'Պատվերի համար', + 'pt.kind.BONUS': 'Բոնուս', + 'pt.f.category': 'Կատեգորիա', + 'pt.f.orderAmount': 'Պատվերի գումար, ֏', + 'pt.f.excluded': 'Չի հաշվարկվում, ֏', + 'pt.f.excludedHint': 'տուրքեր, նյութեր, թեյավճար', + 'pt.f.paidWithPoints': 'Վճարված բարիկներով', + 'pt.f.serviceDate': 'Ծառայության ամսաթիվ', + 'pt.f.orderRef': 'Պատվերի համար', + 'pt.f.orderRefHint': 'նույն համարով երկրորդ անգամ չի կարելի', + 'pt.f.bonusType': 'Բոնուսի տեսակ', + 'pt.f.ttl': 'Ժամկետ, օր', + 'pt.f.ttlDefault': 'լռելյայն՝ {n}', + 'pt.f.ttlNone': 'դատարկ՝ ընդհանուր կանոն', + 'pt.f.amount': 'Բարիկների գումար', + 'pt.f.amountHint': 'առաջարկ՝ {n}', + 'pt.f.reason': 'Պատճառ', + 'pt.f.reasonRequired': 'Պատճառ (պարտադիր)', + 'pt.f.comment': 'Մեկնաբանություն հաճախորդի համար', + 'pt.f.commentPh': 'Կտեսնի պատմության մեջ', + 'pt.f.noWait': 'Առանց սպասման՝ անմիջապես', + 'pt.f.noWaitReason': 'Առանց սպասման (պատճառ պետք է)', + 'pt.f.redeemAmount': 'Որքան դուրս գրել', + 'pt.f.defaultAmount': 'Լռելյայն գումար', + 'pt.formula': '{tier}՝ {rate}%{extra} → {n} բարիկ', + 'pt.accrueBtn': 'Հաշվեգրել', + 'pt.confirmTitle': 'Ստուգեք', + 'pt.edit': 'Փոխել', + 'pt.confirmOrder': 'Հաշվեգրել {n} բարիկ {name}-ին՝ {what} {sum} ֏ ({tier}, {rate}%)։ Հասանելի՝ {date}։', + 'pt.confirmBonus': 'Հաշվեգրել {n} բարիկ {name}-ին՝ {what}, {until}։', + 'pt.now': 'անմիջապես', + 'pt.forDays': '{n} օրով', + 'pt.noTtl': 'առանց ժամկետի', + 'pt.changedNote': 'Գումարը տարբերվում է առաջարկից ({n})՝ կգրանցվի ձեր պատճառով։', + 'pt.accrued': 'Հաշվեգրված է {n}', + 'pt.needsApproval': 'Գերազանցում է սահմանաչափը՝ ուղարկված է սուպերադմինին', + 'pt.zero': 'Գումարը 0 է', + 'pt.reverseAccrual': 'Չեղարկել հաշվեգրումը', + 'pt.reverseRedemption': 'Չեղարկել դուրսգրումը', + 'pt.reverseAccrualText': '{n} բարիկի հաշվեգրումը կչեղարկվի։ Սկզբնական գրառումը կմնա մատյանում։', + 'pt.reverseRedemptionText': '{n} բարիկը կվերադառնա սկզբնական հաշվեգրումներին։', + 'pt.debtWarn': 'Արդեն ծախսված է {spent}՝ մնացորդը կդառնա բացասական՝ {after}։ Կծածկվի հաջորդ հաշվեգրումներով։', + 'pt.reasonPh': 'Վերադարձ, սխալ…', + 'pt.reverseHint': 'Աջակցությունը չեղարկում է միայն իր գործողությունները 24 ժամում։', + 'pt.reversed': 'Չեղարկված է', + 'pt.maxHint': 'առավելագույնը {max} ({share}%), նվազագույնը {min}', + 'pt.redeemReasonPh': 'Զանգով, պատվեր հավելվածից դուրս…', + 'pt.toPay': 'Մնաց վճարել՝ {sum} ֏', + 'pt.noRequests': 'Հայտեր չկան։', + 'pt.wants': 'ուզում է դուրս գրել {n}', + 'pt.forOrder': 'պատվեր №{n}', + 'pt.noOrder': 'պատվեր հավելվածից դուրս', + 'pt.balanceN': 'մնացորդ {n}', + 'pt.confirm': 'Հաստատել', + 'pt.confirmN': 'Դուրս գրել {n}', + 'pt.confirmed': 'Դուրս է գրված {n}', + 'pt.reject': 'Մերժել', + 'pt.rejectReason': 'Պատճառը՝ կտեսնի հաճախորդը', + 'pt.noSuggestions': 'Առաջարկներ չկան։', + 'pt.suggestionsHint': 'Համակարգը գտել է, ում է հասնում բոնուս։ Հաշվեգրեք, փոխեք գումարը կամ մերժեք։', + 'pt.mech.first_month_route': 'Առաջին ամսվա երթուղի', + 'pt.mech.photo_review': 'Կարծիք լուսանկարով', + 'pt.mech.over_limit': 'Աջակցության սահմանաչափից բարձր', + 'pt.why.first_month_route': 'առաջին 30 օրում 3 տարբեր կատեգորիա', + 'pt.why.photo_review': 'լուսանկար է ուղարկել №{n}-ից հետո', + 'pt.why.over_limit': '{by}՝ {op}', + 'pt.op.accrual': 'հաշվեգրում', + 'pt.op.redemption': 'դուրսգրում', + 'pt.approve': 'Հաստատել', + 'pt.changeAmount': 'Փոխել գումարը', + 'pt.allTypes': 'Բոլոր տեսակները', + 'pt.allCategories': 'Բոլոր կատեգորիաները', + 'pt.allChannels': 'Բոլոր ալիքները', + 'pt.allClients': 'Բոլոր հաճախորդները', + 'pt.type.accrual': 'Հաշվեգրում', + 'pt.type.bonus': 'Բոնուս', + 'pt.type.redemption': 'Դուրսգրում', + 'pt.type.request': 'Հաճախորդի հայտ', + 'pt.type.reversal': 'Չեղարկում', + 'pt.channel.WEB': 'ադմին', + 'pt.channel.BOT': 'բոտ', + 'pt.channel.CSV': 'CSV', + 'pt.channel.AUTO': 'ավտո', + 'pt.channel.CLIENT': 'հաճախորդ', + 'pt.status.PENDING': 'սպասման մեջ', + 'pt.status.ACTIVE': 'ակտիվ', + 'pt.status.SPENT': 'ծախսված', + 'pt.status.EXPIRED': 'այրված', + 'pt.status.REVERSED': 'չեղարկված', + 'pt.status.REQUESTED': 'սպասում է', + 'pt.status.CONFIRMED': 'դուրս գրված', + 'pt.status.REJECTED': 'մերժված', + 'pt.status.ACCRUAL': 'հաշվեգրման', + 'pt.status.REDEMPTION': 'դուրսգրման', + 'pt.col.date': 'Երբ', + 'pt.col.client': 'Հաճախորդ', + 'pt.col.what': 'Ինչ', + 'pt.col.amount': 'Բարիկներ', + 'pt.col.status': 'Կարգավիճակ', + 'pt.col.author': 'Ով', + 'pt.system': 'համակարգ', + 'pt.suggestedN': 'առաջարկ {n}', + 'pt.debtN': 'պարտք {n}', + 'pt.rep.accrued': 'Հաշվեգրված', + 'pt.rep.spent': 'Ծախսված', + 'pt.rep.expired': 'Այրված', + 'pt.rep.reversed': 'Չեղարկված', + 'pt.rep.liabilities': 'Պարտավորություններ', + 'pt.rep.rate': 'Ծախսման տոկոս', + 'pt.rep.byCategory': 'Ծրագրի արժեքը ըստ կատեգորիաների', + 'pt.rep.cost': 'հասույթի {pct}%՝ {revenue} ֏', + 'pt.rep.retention': 'Կրկնակի պատվեր 30 օրում', + 'pt.rep.with': 'Բարիկներով', + 'pt.rep.without': 'Առանց բարիկների', + 'pt.rep.clientsN': '{n} հաճախորդ, 2+ կատեգորիայով՝ {multi}', + 'pt.rep.topClients': 'Թոփ ըստ մնացորդի', + 'pt.rep.topAgencies': 'Գործակալություններ՝ բնակարանամուտի ակտիվացումներ', + 'pt.importHint': 'CSV՝ հեռախոս, կատեգորիա, պատվերի գումար, ամսաթիվ (ՏՏՏՏ-ԱԱ-ՕՕ), պատվերի համար։ Նախ՝ նախադիտում։', + 'pt.importRows': 'Տողեր', + 'pt.importTotal': 'Ընդամենը բարիկ', + 'pt.importErr.client': 'հաճախորդը չգտնվեց', + 'pt.importErr.category': 'անհայտ կատեգորիա', + 'pt.importErr.date': 'սխալ ամսաթիվ', + 'pt.importErr.duplicate': 'կրկնվող պատվեր', + 'pt.importCommit': 'Հաշվեգրել {n} տող', + 'pt.importDone': 'Հաշվեգրված է {n} տող', + 'pt.categories': 'Կատեգորիաներ և տոկոսներ', + 'pt.bonusTypes': 'Բոնուսների տեսակներ', + 'pt.code': 'Կոդ', + 'pt.activeLabel': 'Ակտիվ', + 'pt.rulesHint': 'Հասանելի՝ {hours} ժ հետո · մինչև {share}%, նվազագույնը {min} · այրվում են {burn} օր անգործությունից հետո։', + 'err.POINTS_DUPLICATE_ORDER': 'Այս պատվերի համար արդեն հաշվեգրվել է։', + 'err.POINTS_REASON_REQUIRED': 'Նշեք պատճառը։', + 'err.POINTS_BELOW_MIN': 'Նվազագույնից քիչ է։', + 'err.POINTS_NOT_ENOUGH': 'Բարիկները չեն բավարարում։', + 'err.POINTS_OVER_SHARE': 'Ավելի է թույլատրելի մասից։', + 'err.POINTS_OVER_LIMIT': 'Սահմանաչափից բարձր է։', + 'err.POINTS_FINAL': 'Այլևս հնարավոր չէ չեղարկել։', + 'err.POINTS_REQUEST_CLOSED': 'Հայտն արդեն մշակված է։', + 'err.POINTS_WITH_PROMO': 'Բարիկները չեն համատեղվում պրոմոկոդի հետ։', + 'pt.formulaStreak': ' + շարք {streak}՝ +{bonus}%', + 'ord.editComposition': 'Փոխել կազմը', + 'ord.editTitle': '№{n} պատվերի կազմը', + 'ord.editPrice': 'Գինը՝ {was} → {now} (զեղչ {pct}%)', + 'ord.editDuration': 'Տևողություն՝ {was} → {now} րոպե', + 'ord.editBusy': 'Պատվերը երկարեց, իսկ կատարողն այդ ժամին զբաղված է։', + 'ord.editPaid': 'Արդեն վճարված է․ կվերահաշվարկվեն պարգևը, գործընկերոջ եկամուտը և բարիկները։', + 'ord.editOwnPrice': 'Սեփական գին, ֏', + 'ord.editOwnPriceHint': 'դատարկ՝ գնացուցակով՝ {n}', + 'ord.editReason': 'Պատճառ', + 'ord.editReasonRequired': 'Պատճառ (պարտադիր)', + 'ord.editReasonPh': 'Տեղում ավելացրին պատուհաններ…', + 'ord.editNotify': 'Տեղեկացնել հաճախորդին և կատարողին', + 'ord.edited': 'Պատվերը փոխված է', + 'ord.editedBusy': 'Փոխված է։ Ստուգեք կատարողի գրաֆիկը', + 'ord.uncheck': 'Հանել նշումը', + 'ord.checkForProvider': 'Նշել կատարողի փոխարեն', + 'ord.noPhotoReason': 'առանց լուսանկարի՝ {reason}', + 'ord.byStaff': 'նշել է թիմը', + 'ord.checkNoPhoto': 'Նշել առանց լուսանկարի', + 'ord.photoHint': 'Այս կետի համար պետք է լուսանկար։', + 'ord.uploadPhoto': 'Վերբեռնել լուսանկար', + 'ord.noPhotoField': 'Կամ պատճառը, թե ինչու առանց լուսանկարի', + 'ord.noPhotoPh': 'Մաքրողն ուղարկել է զրույցում…', + 'err.PHOTO_REQUIRED': 'Պետք է լուսանկար կամ պատճառ։', }; const DICTS: Record = { ru, en, hy }; diff --git a/apps/backoffice/src/orders.css b/apps/backoffice/src/orders.css index 0792153..5bdad6e 100644 --- a/apps/backoffice/src/orders.css +++ b/apps/backoffice/src/orders.css @@ -379,8 +379,12 @@ margin: 0; padding: 0; display: grid; + grid-template-columns: minmax(0, 1fr); gap: 6px; } +.bo-cands .bo-small { + overflow-wrap: anywhere; +} .bo-cands li { display: flex; align-items: center; @@ -393,6 +397,15 @@ background: var(--bc-home); color: var(--bc-home-ink); } +/* самый выгодный для нас из доступных — обводка, чтобы оператор видел его и среди первых по подбору */ +.bo-cands li[data-best] { + outline: 2px solid var(--bc-plum); + outline-offset: -2px; +} +.bo-cands li[data-best] .bo-chip--ok { + background: var(--bc-plum); + color: #fff; +} .bo-cands__rank { width: 26px; height: 26px; @@ -698,3 +711,23 @@ display: none; } } + +/* чек-лист: команда отмечает за исполнителя */ +.bo-checks__toggle { + display: inline-flex; + align-items: center; + gap: 10px; + border: 0; + background: none; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; + text-align: left; +} +.bo-checks__toggle:disabled { + cursor: default; +} +.bo-checks__toggle:hover:not(:disabled) .bo-checks__box { + border-color: var(--bc-accent); +} diff --git a/apps/backoffice/src/pages/Admin5.tsx b/apps/backoffice/src/pages/Admin5.tsx index 8ac2332..96f5a97 100644 --- a/apps/backoffice/src/pages/Admin5.tsx +++ b/apps/backoffice/src/pages/Admin5.tsx @@ -261,8 +261,11 @@ export function Journal() { 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: 'subs', keys: ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'elite.monthlyPrice', 'elite.maxDiscountPct'] }, + // подарок новым клиентам задаётся у каждого агентства; giftDays — только число в текстах лендинга + { key: 'agencies', keys: ['agency.attributionMonths', 'agency.defaultCommissionPct', 'barevPlus.giftDays'] }, + { key: 'points', keys: ['points.pendingHours', 'points.maxSharePct', 'points.minRedeem', 'points.burnDays', 'points.warnDays1', 'points.warnDays2', 'points.requestTtlHours', 'points.streakGapDays', 'points.diffHighlightPct', 'points.supportCanManage', 'points.opMaxPerOperation', 'points.opMaxPerDay'] }, + { key: 'partners', keys: ['partner.fotPct', 'partner.taxPct', 'partner.minTravelGapMinutes', 'partner.acceptTimeoutMinutes'] }, { 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'] }, diff --git a/apps/backoffice/src/pages/Agencies.tsx b/apps/backoffice/src/pages/Agencies.tsx index 834b092..e58a05a 100644 --- a/apps/backoffice/src/pages/Agencies.tsx +++ b/apps/backoffice/src/pages/Agencies.tsx @@ -26,6 +26,15 @@ function CopyLink({ url }: { url: string }) { } export { CopyLink }; +const GIFT_MONTHS = [1, 2, 3, 6, 12] as const; +type GiftMonths = (typeof GIFT_MONTHS)[number]; +const PLAN_NAME = { BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' } as const; + +/** «Barev+ на 3 мес.» или «без подарка» */ +function giftText(t: ReturnType['t'], a: { giftPlan: 'BAREV_PLUS' | 'ELITE' | null; giftMonths: number }) { + return a.giftPlan ? t('ag.giftValue', { plan: PLAN_NAME[a.giftPlan], n: a.giftMonths }) : t('ag.noGift'); +} + export function Agencies() { const { t, amd } = useT(); const trpc = useTRPC(); @@ -64,7 +73,7 @@ export function Agencies() { {a.name} - {[a.code, `${a.commissionPct}%`, a.sales?.name].filter(Boolean).join(' · ')} + {[a.code, `${a.commissionPct}%`, giftText(t, a), a.sales?.name].filter(Boolean).join(' · ')} {!a.active && {t('ag.inactive')}} @@ -125,6 +134,8 @@ export function AgencyCard({ id }: { id: string }) {
{d.code} {t('ag.pct', { n: d.commissionPct })} + 🎁 {giftText(t, d)} + {d.housewarmingPoints > 0 && 🪙 {t('ag.hwValue', { n: new Intl.NumberFormat('ru-RU').format(d.housewarmingPoints), days: d.housewarmingDays })}} {d.sales && {t('ag.byS', { name: d.sales.name })}} {t('prv.since', { date: date(d.createdAt) })} {!d.active && {t('ag.inactive')}} @@ -227,7 +238,7 @@ export function AgencyCard({ id }: { id: string }) { ); } -function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code: string; name: string; commissionPct: number; contact: string | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) { +function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code: string; name: string; commissionPct: number; giftPlan: 'BAREV_PLUS' | 'ELITE' | null; giftMonths: number; housewarmingPoints: number; housewarmingDays: number; contact: string | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) { const { t } = useT(); const trpc = useTRPC(); const qc = useQueryClient(); @@ -237,6 +248,10 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code code: agency?.code ?? '', name: agency?.name ?? '', commissionPct: String(agency?.commissionPct ?? 10), + giftPlan: (agency ? (agency.giftPlan ?? 'NONE') : 'BAREV_PLUS') as 'BAREV_PLUS' | 'ELITE' | 'NONE', + giftMonths: (GIFT_MONTHS.includes(agency?.giftMonths as GiftMonths) ? agency!.giftMonths : 1) as GiftMonths, + hwPoints: String(agency?.housewarmingPoints ?? 0), + hwDays: String(agency?.housewarmingDays ?? 60), contact: agency?.contact ?? '', note: agency?.note ?? '', active: agency?.active ?? true, @@ -267,7 +282,7 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code type="button" className="bo-btn" disabled={!f.name.trim() || !f.code.trim() || save.isPending} - onClick={() => save.mutate({ id: agency?.id, code: f.code, name: f.name, commissionPct: Math.min(100, Number(f.commissionPct) || 0), contact: f.contact || null, note: f.note || null, active: f.active, salesId: f.salesId || null })} + onClick={() => save.mutate({ id: agency?.id, code: f.code, name: f.name, commissionPct: Math.min(100, Number(f.commissionPct) || 0), giftPlan: f.giftPlan === 'NONE' ? null : f.giftPlan, giftMonths: f.giftMonths, housewarmingPoints: Number(f.hwPoints) || 0, housewarmingDays: Number(f.hwDays) || 60, contact: f.contact || null, note: f.note || null, active: f.active, salesId: f.salesId || null })} > {t('ui.save')} @@ -297,6 +312,32 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
+
+ + + + + + +
+
+ + setF({ ...f, hwPoints: e.target.value.replace(/\D/g, '').slice(0, 7) })} /> + + + setF({ ...f, hwDays: e.target.value.replace(/\D/g, '').slice(0, 4) })} /> + +
setF({ ...f, contact: e.target.value })} placeholder={t('ag.contactPh')} /> diff --git a/apps/backoffice/src/pages/ClientCard.tsx b/apps/backoffice/src/pages/ClientCard.tsx index d322ad1..c121b39 100644 --- a/apps/backoffice/src/pages/ClientCard.tsx +++ b/apps/backoffice/src/pages/ClientCard.tsx @@ -1,3 +1,4 @@ +import { ClientPoints } from './Points.tsx'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { navigate, useToast, useTRPC } from '@barev/web'; import { myChannels } from './Chats.tsx'; @@ -234,6 +235,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
+ {isSuper && } {d.money && (
diff --git a/apps/backoffice/src/pages/Moves.tsx b/apps/backoffice/src/pages/Moves.tsx index 5f7a78c..30c5b2e 100644 --- a/apps/backoffice/src/pages/Moves.tsx +++ b/apps/backoffice/src/pages/Moves.tsx @@ -519,14 +519,17 @@ export function Requests({ me }: { me: Me }) { №{r.number} {shared(`services.${r.service.code}.name`)} - {shared(`admin.webRequests.status_${r.status}`)} + + {r.status !== 'ORDERED' && r.status !== 'CANCELLED' && (!r.preferredAt || !r.district || !r.addressText) && {t('rq.missing')}} + {shared(`admin.webRequests.status_${r.status}`)} +
{paramsSummary(sharedT, r.service.code, r.params as Record)}
- {t('rq.wish')}: {dateTime(r.preferredAt)} · {t('rq.price')}: {amd(r.totalPrice)} + {t('rq.wish')}: {r.preferredAt ? dateTime(r.preferredAt) : t('rq.noTime')} · {t('rq.price')}: {amd(r.totalPrice)}
- {shared(`districts.${r.district.code}`)}, {r.addressText} + {[r.district ? shared(`districts.${r.district.code}`) : t('rq.noDistrict'), r.addressText ?? t('rq.noAddress')].join(', ')}
diff --git a/apps/backoffice/src/pages/NewOrder.tsx b/apps/backoffice/src/pages/NewOrder.tsx index 0643357..13376b2 100644 --- a/apps/backoffice/src/pages/NewOrder.tsx +++ b/apps/backoffice/src/pages/NewOrder.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react'; import { errText, useT } from '../i18n.tsx'; import { Avatar, Dialog, Field, I, PlanBadge } from '../components/kit.tsx'; -type ParamField = +export type ParamField = | { key: string; type: 'select'; options: string[]; default?: string } | { key: string; type: 'int'; min: number; max: number; default?: number } | { key: string; type: 'text'; maxLength: number; required?: boolean } @@ -262,7 +262,7 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?: ); } -function ParamsForm({ service, fields, value, onChange }: { service: string; fields: ParamField[]; value: Params; onChange: (v: Params) => void }) { +export function ParamsForm({ service, fields, value, onChange }: { service: string; fields: ParamField[]; value: Params; onChange: (v: Params) => void }) { const { t, shared } = useT(); const label = (k: string) => { const key = `params.${service}.${k}.label`; diff --git a/apps/backoffice/src/pages/OrderCard.tsx b/apps/backoffice/src/pages/OrderCard.tsx index bf5d6f3..cde03cc 100644 --- a/apps/backoffice/src/pages/OrderCard.tsx +++ b/apps/backoffice/src/pages/OrderCard.tsx @@ -5,7 +5,7 @@ import type { Me } from '../App.tsx'; import { errText, useT } from '../i18n.tsx'; import { Avatar, Dialog, Field, I, PlanBadge, Skeleton } from '../components/kit.tsx'; import { GROUP_DOT, statusLabel } from './Orders.tsx'; -import { SlotPicker } from './NewOrder.tsx'; +import { ParamsForm, SlotPicker, type ParamField } from './NewOrder.tsx'; const MIN = 60_000; @@ -15,7 +15,8 @@ export function OrderCard({ id, me }: { id: string; me: Me }) { const qc = useQueryClient(); const toast = useToast(); const o = useQuery({ ...trpc.bo.orders.get.queryOptions({ id }), refetchInterval: 15_000 }); - const [dlg, setDlg] = useState<'reschedule' | 'cancel' | null>(null); + const [dlg, setDlg] = useState<'reschedule' | 'cancel' | 'edit' | null>(null); + const [check, setCheck] = useState<{ code: string; requiresPhoto: boolean } | null>(null); const refresh = () => { qc.invalidateQueries({ queryKey: trpc.bo.orders.get.queryKey({ id }) }); qc.invalidateQueries({ queryKey: trpc.bo.orders.candidates.queryKey({ id }) }); @@ -26,6 +27,7 @@ export function OrderCard({ id, me }: { id: string; me: Me }) { const onError = (e: unknown) => toast(errText(t, e), 'error'); const transition = useMutation(trpc.bo.orders.transition.mutationOptions({ onSuccess: () => (refresh(), toast(t('ord.statusChanged'))), onError })); const markPaid = useMutation(trpc.bo.orders.markPaid.mutationOptions({ onSuccess: () => (refresh(), toast(t('ord.paidDone'))), onError })); + const setChecklist = useMutation(trpc.bo.orders.checklist.mutationOptions({ onSuccess: refresh, onError })); const chat = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError })); const canChat = me.roles.some((r) => r === 'SUPERADMIN' || r === 'SUPPORT' || r === 'CONCIERGE'); @@ -92,6 +94,11 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {

{t('ord.details')}

+ {d.status !== 'CANCELLED' && ( + + )}
{t('ord.when')}
@@ -143,9 +150,19 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
    {d.checklist.map((c) => (
  • - {c.done && } - {shared(`checklist.${c.code}`)} - {c.requiresPhoto && !c.fileId && · {t('ord.needsPhoto')}} + + {c.requiresPhoto && !c.fileId && !c.waived && · {t('ord.needsPhoto')}} + {c.waived && · {t('ord.noPhotoReason', { reason: c.waived })}} + {c.byStaff && · {t('ord.byStaff')}}
  • ))}
@@ -332,6 +349,8 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
{dlg === 'reschedule' && setDlg(null)} onDone={refresh} />} + {dlg === 'edit' && setDlg(null)} onDone={refresh} />} + {check && setCheck(null)} onDone={refresh} />} {dlg === 'cancel' && setDlg(null)} onDone={refresh} />} ); @@ -379,7 +398,7 @@ function useOrderData(id: string) { } function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onChanged: () => void; canChat: boolean }) { - const { t, shared } = useT(); + const { t, shared, amd } = useT(); const trpc = useTRPC(); const toast = useToast(); const [showBusy, setShowBusy] = useState(false); @@ -464,15 +483,28 @@ function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onC ) : (
    {cands.data.free.map((c, i) => ( -
  • +
  • {i + 1} {c.name} - {c.kind === 'company' && {t('ord.company')}} + {c.kind === 'company' && {t('ord.partner')}} + {c.best && {t('ord.best')}} {c.tried && {t('ord.tried')}} - {[c.rating != null ? `★ ${c.rating}` : null, c.languages.join(', ').toUpperCase(), ...c.reasons.map((r) => shared(`admin.reasons.${r}`)), t('ord.load', { n: c.load })].filter(Boolean).join(' · ')} + {[ + c.rating != null ? `★ ${c.rating}` : null, + c.languages.join(', ').toUpperCase(), + ...c.reasons.map((r) => shared(`admin.reasons.${r}`)), + c.kind === 'company' && c.partnerFree != null ? t('ord.partnerFree', { n: c.partnerFree, of: c.partnerStaff ?? 0 }) : t('ord.load', { n: c.load }), + ] + .filter(Boolean) + .join(' · ')} + {c.margin != null && ( + + {t('ord.candMoney', { cost: amd(c.cost ?? 0), margin: amd(c.margin) })} + + )} + + + } + > + {fields.length > 0 && } + {p.isError &&

    {errText(t, p.error)}

    } + {p.data && ( +
    + {t('ord.editPrice', { was: amd(p.data.was.totalPrice), now: amd(p.data.totalPrice), pct: p.data.discountPct })} +
    + {t('ord.editDuration', { was: p.data.was.durationMinutes, now: p.data.durationMinutes })} + {p.data.providerBusy && ( + <> +
    ⚠️ {t('ord.editBusy')} + + )} + {p.data.paid && ( + <> +
    💳 {t('ord.editPaid')} + + )} +
    + )} +
    + + setPrice(e.target.value.replace(/\D/g, '').slice(0, 8))} /> + + + setReason(e.target.value)} placeholder={t('ord.editReasonPh')} /> + +
    + + + ); +} + +/** Пункт с фото: загрузить фото или отметить без фото с причиной */ +function ChecklistPhotoDialog({ orderId, code, onClose, onDone }: { orderId: string; code: string; onClose: () => void; onDone: () => void }) { + const { t, shared } = useT(); + const trpc = useTRPC(); + const toast = useToast(); + const [reason, setReason] = useState(''); + const [busy, setBusy] = useState(false); + const mark = useMutation(trpc.bo.orders.checklist.mutationOptions({ onSuccess: () => (onDone(), onClose()), onError: (e) => toast(errText(t, e), 'error') })); + const upload = async (file: File) => { + setBusy(true); + const fd = new FormData(); + fd.append('code', code); + fd.append('file', file); + const res = await fetch(`/files/bo/checklist-photo/${orderId}`, { method: 'POST', body: fd, credentials: 'include' }).catch(() => null); + setBusy(false); + if (!res?.ok) { + const err = (await res?.json().catch(() => null)) as { error?: string } | null; + toast(errText(t, { message: err?.error ?? 'NETWORK' }), 'error'); + return; + } + onDone(); + onClose(); + }; + return ( + + + + + } + > +

    + {t('ord.photoHint')} +

    + + + setReason(e.target.value)} placeholder={t('ord.noPhotoPh')} /> + +
    + ); +} diff --git a/apps/backoffice/src/pages/Points.tsx b/apps/backoffice/src/pages/Points.tsx new file mode 100644 index 0000000..cb9e72d --- /dev/null +++ b/apps/backoffice/src/pages/Points.tsx @@ -0,0 +1,1089 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { navigate, useLocation, useToast, useTRPC } from '@barev/web'; +import { useEffect, useMemo, useState } from 'react'; +import { errText, useT, type BoLocale } from '../i18n.tsx'; +import { Dialog, download, Field, I, Skeleton } from '../components/kit.tsx'; + +type Tab = 'accrue' | 'requests' | 'suggestions' | 'ledger' | 'report' | 'import' | 'setup'; +const TABS: Tab[] = ['accrue', 'requests', 'suggestions', 'ledger', 'report', 'import', 'setup']; +const TIER = { BAREV: 'Barev', BAREV_PLUS: 'Barev+', ELITE: 'Elite' } as Record; +const pick = (v: Record | null | undefined, l: BoLocale) => (v ? v[l] || v.ru || v.en || '' : ''); +const key = () => (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); +const fmt = (n: number) => new Intl.NumberFormat('ru-RU').format(n); +const todayKey = () => new Date(Date.now() + 4 * 3600_000).toISOString().slice(0, 10); + +/** Барики: начисление, запросы клиентов, предложения системы, журнал, отчёт, импорт, справочники */ +export function Points() { + const { t } = useT(); + const trpc = useTRPC(); + const { query } = useLocation(); + const tab = (TABS.includes(query.get('tab') as Tab) ? query.get('tab') : 'accrue') as Tab; + const reqs = useQuery({ ...trpc.bo.points.requests.queryOptions(), refetchInterval: 30_000 }); + const sugg = useQuery({ ...trpc.bo.points.suggestions.queryOptions(), refetchInterval: 60_000 }); + const dict = useQuery(trpc.bo.points.dictionaries.queryOptions()); + const go = (t2: Tab) => navigate(`/points?tab=${t2}`); + const count: Partial> = { requests: reqs.data?.length, suggestions: sugg.data?.length }; + const tabs = TABS.filter((x) => dict.data?.owner || !['report', 'setup', 'suggestions'].includes(x)); + return ( + <> +
    +

    {t('pt.title')}

    +
    +
    + {tabs.map((x) => ( + + ))} +
    + {!dict.data ? ( + + ) : tab === 'accrue' ? ( + + ) : tab === 'requests' ? ( + + ) : tab === 'suggestions' ? ( + + ) : tab === 'ledger' ? ( + + ) : tab === 'report' ? ( + + ) : tab === 'import' ? ( + + ) : ( + + )} + + ); +} + +type Dict = NonNullable['data']>; +function useDict() { + const trpc = useTRPC(); + return useQuery(trpc.bo.points.dictionaries.queryOptions()); +} + +// ───── начисление ───── + +function Accrue({ dict, initialClient }: { dict: Dict; initialClient: string | null }) { + const { t } = useT(); + const trpc = useTRPC(); + const [q, setQ] = useState(''); + const [clientId, setClientId] = useState(initialClient); + const found = useQuery({ ...trpc.bo.points.search.queryOptions({ q }), enabled: q.trim().length >= 2 }); + return ( +
    +
    +
    + + setQ(e.target.value)} autoFocus /> + + {found.data && ( +
      + {found.data.length === 0 &&
    • {t('pt.notFound')}
    • } + {found.data.map((c) => ( +
    • setClientId(c.id)}> + + {c.name} + + {[c.phone, c.username && `@${c.username}`, TIER[c.tier]].filter(Boolean).join(' · ')} + + + {fmt(c.balance)} +
    • + ))} +
    + )} +
    + {clientId && } +
    +
    {clientId ? :
    {t('pt.pickClient')}
    }
    +
    + ); +} + +/** Карточка клиента: баланс, ожидание, сгорание, последние операции с отменой */ +export function ClientPoints({ id, compact }: { id: string; compact?: boolean }) { + const { t, date, locale } = useT(); + const trpc = useTRPC(); + const c = useQuery(trpc.bo.points.client.queryOptions({ id })); + const [rev, setRev] = useState<{ type: 'ACCRUAL' | 'REDEMPTION'; id: string } | null>(null); + const [redeem, setRedeem] = useState(false); + if (!c.data) return ; + const d = c.data; + return ( +
    +
    +

    {compact ? t('pt.title') : d.name}

    + {d.flagged && {t('pt.flagged')}} +
    +
    + + {t('pt.balance')} + {fmt(d.balance)} + + + {t('pt.pending')} + {fmt(d.pending)} + {d.pendingNextAt && {t('pt.availableFrom', { date: date(d.pendingNextAt) })}} + +
    +
    +
    {t('pt.tier')}
    +
    {TIER[d.tier]}
    +
    {t('pt.burnAt')}
    +
    {d.burnAt ? date(d.burnAt) : '—'}
    +
    {t('pt.agency')}
    +
    {d.agency ?? '—'}
    +
    {t('pt.streak')}
    +
    {d.streak}
    + {d.debt > 0 && ( + <> +
    {t('pt.debt')}
    +
    {fmt(d.debt)}
    + + )} +
    +
    + + {compact && ( + + {t('pt.accrueBtn')} + + )} + + {t('pt.allOps')} + +
    + {d.history.length === 0 ? ( +

    {t('pt.noOps')}

    + ) : ( +
      + {d.history.map((h) => ( +
    • + + {h.type === 'bonus' ? pick(h.bonus, locale) : h.type === 'accrual' ? pick(h.category, locale) : t(`pt.type.${h.type}`)} + {h.orderRef && {h.orderRef}} + + {date(h.at)} · {t(`pt.status.${h.status}`)} + + + + {h.amount > 0 ? '+' : '−'} + {fmt(Math.abs(h.amount))} + + {((h.type === 'accrual' || h.type === 'bonus') && (h.status === 'PENDING' || h.status === 'ACTIVE')) || (h.type === 'redemption' && h.status === 'CONFIRMED') ? ( + + ) : null} +
    • + ))} +
    + )} + {rev && setRev(null)} />} + {redeem && setRedeem(false)} />} +
    + ); +} + +function AccrueForm({ dict, clientId }: { dict: Dict; clientId: string }) { + const { t, locale, date } = useT(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const [kind, setKind] = useState<'ORDER' | 'BONUS'>('ORDER'); + const cats = dict.categories.filter((c) => c.active); + const bonuses = dict.bonusTypes.filter((b) => b.active); + const [f, setF] = useState({ categoryId: cats[0]?.id ?? '', bonusTypeId: bonuses[0]?.id ?? '', orderAmount: '', excluded: '', paidWithPoints: '', serviceDate: todayKey(), orderRef: '', amount: '', reason: '', comment: '', noWait: false, ttl: '' }); + const [confirm, setConfirm] = useState(false); + const [idem, setIdem] = useState(key); + useEffect(() => setIdem(key()), [clientId]); + const serviceDate = new Date(`${f.serviceDate}T12:00:00+04:00`); + const preview = useQuery({ + ...trpc.bo.points.preview.queryOptions({ clientId, categoryId: f.categoryId, orderAmount: Number(f.orderAmount) || 0, excludedAmount: Number(f.excluded) || 0, paidWithPoints: Number(f.paidWithPoints) || 0, serviceDate }), + enabled: kind === 'ORDER' && !!f.categoryId && !Number.isNaN(serviceDate.getTime()), + }); + const client = useQuery(trpc.bo.points.client.queryOptions({ id: clientId })); + const bonus = bonuses.find((b) => b.id === f.bonusTypeId); + const suggested = kind === 'ORDER' ? (preview.data?.suggested ?? 0) : (bonus?.defaultAmount ?? 0); + const amount = f.amount === '' ? suggested : Number(f.amount); + const changed = f.amount !== '' && amount !== suggested; + const needReason = changed || (f.noWait && !dict.owner); + const ttlDefault = bonus?.ttlDays ?? null; + const ttl = f.ttl === '' ? ttlDefault : Number(f.ttl); + const save = useMutation( + trpc.bo.points.accrue.mutationOptions({ + onSuccess: (r) => { + toast(r.status === 'needs_approval' ? t('pt.needsApproval') : r.status === 'skipped' ? t('pt.zero') : t('pt.accrued', { n: fmt(r.status === 'created' ? r.amount : 0) })); + void qc.invalidateQueries(); + setConfirm(false); + setIdem(key()); + setF({ ...f, orderAmount: '', excluded: '', paidWithPoints: '', orderRef: '', amount: '', reason: '', comment: '', noWait: false, ttl: '' }); + }, + onError: (e) => toast(errText(t, e), 'error'), + }), + ); + const submit = () => + save.mutate({ + clientId, + kind, + categoryId: kind === 'ORDER' ? f.categoryId : undefined, + bonusTypeId: kind === 'BONUS' ? f.bonusTypeId : undefined, + orderAmount: kind === 'ORDER' ? Number(f.orderAmount) || 0 : undefined, + excludedAmount: Number(f.excluded) || undefined, + paidWithPoints: Number(f.paidWithPoints) || undefined, + serviceDate, + orderRef: f.orderRef.trim() || null, + amount: f.amount === '' ? undefined : amount, + reason: f.reason.trim() || null, + commentForClient: f.comment.trim() || null, + noWait: f.noWait, + ttlDays: kind === 'BONUS' && f.ttl !== '' ? ttl : undefined, + idempotencyKey: idem, + }); + const num = (v: string) => v.replace(/\D/g, '').slice(0, 9); + const cat = cats.find((c) => c.id === f.categoryId); + const valid = amount > 0 && (!needReason || f.reason.trim()) && (kind === 'BONUS' || Number(f.orderAmount) > 0); + return ( +
    +
    +

    {t('pt.accrueTitle')}

    +
    +
    + {(['ORDER', 'BONUS'] as const).map((k) => ( + + ))} +
    + {kind === 'ORDER' ? ( + <> +
    + + + + + setF({ ...f, orderAmount: num(e.target.value) })} /> + +
    +
    + + setF({ ...f, excluded: num(e.target.value) })} /> + + + setF({ ...f, paidWithPoints: num(e.target.value) })} /> + +
    +
    + + setF({ ...f, serviceDate: e.target.value })} /> + + + setF({ ...f, orderRef: e.target.value })} /> + +
    + {preview.data && ( +

    + {t('pt.formula', { tier: TIER[preview.data.tier], rate: preview.data.baseRatePct, extra: preview.data.streakBonusPct ? t('pt.formulaStreak', { streak: preview.data.streak, bonus: preview.data.streakBonusPct }) : '', n: fmt(preview.data.suggested) })} +

    + )} + + ) : ( +
    + + + + + setF({ ...f, ttl: num(e.target.value).slice(0, 4) })} /> + +
    + )} +
    + + setF({ ...f, amount: num(e.target.value) })} /> + + + setF({ ...f, reason: e.target.value })} /> + +
    + + setF({ ...f, comment: e.target.value })} /> + + {kind === 'ORDER' && ( + + )} + + {confirm && ( + setConfirm(false)} + title={t('pt.confirmTitle')} + footer={ + <> + + + + } + > +

    + {kind === 'ORDER' + ? t('pt.confirmOrder', { + n: fmt(amount), + name: client.data?.name ?? '', + what: pick(cat?.name, locale).toLowerCase(), + sum: fmt(Number(f.orderAmount) || 0), + tier: TIER[preview.data?.tier ?? 'BAREV'], + rate: preview.data?.ratePct ?? 0, + date: f.noWait || !preview.data ? t('pt.now') : date(preview.data.availableAt), + }) + : t('pt.confirmBonus', { n: fmt(amount), name: client.data?.name ?? '', what: pick(bonus?.name, locale), until: ttl ? t('pt.forDays', { n: ttl }) : t('pt.noTtl') })} +

    + {changed &&

    {t('pt.changedNote', { n: fmt(suggested) })}

    } +
    + )} +
    + ); +} + +function ReverseDialog({ target, onClose }: { target: { type: 'ACCRUAL' | 'REDEMPTION'; id: string }; onClose: () => void }) { + const { t } = useT(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const p = useQuery(trpc.bo.points.reversePreview.queryOptions(target)); + const [reason, setReason] = useState(''); + const save = useMutation( + trpc.bo.points.reverse.mutationOptions({ + onSuccess: () => { + toast(t('pt.reversed')); + void qc.invalidateQueries(); + onClose(); + }, + onError: (e) => toast(errText(t, e), 'error'), + }), + ); + return ( + + + + + } + > + {p.data && ( +

    + {target.type === 'REDEMPTION' ? t('pt.reverseRedemptionText', { n: fmt(p.data.amount) }) : t('pt.reverseAccrualText', { n: fmt(p.data.amount) })} +

    + )} + {p.data && p.data.spent > 0 &&
    {t('pt.debtWarn', { spent: fmt(p.data.spent), after: fmt(p.data.balanceAfter) })}
    } + + setReason(e.target.value)} placeholder={t('pt.reasonPh')} /> + +

    {t('pt.reverseHint')}

    +
    + ); +} + +function RedeemDialog({ clientId, balance, onClose }: { clientId: string; balance: number; onClose: () => void }) { + const { t } = useT(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const dict = useDict(); + const [f, setF] = useState({ orderAmount: '', amount: '', orderRef: '', reason: '' }); + const [idem] = useState(key); + const share = dict.data?.rules.sharePct ?? 30; + const min = dict.data?.rules.min ?? 1000; + const max = f.orderAmount ? Math.min(balance, Math.floor((Number(f.orderAmount) * share) / 100)) : balance; + const save = useMutation( + trpc.bo.points.redeem.mutationOptions({ + onSuccess: (r) => { + toast(r.status === 'needs_approval' ? t('pt.needsApproval') : t('pt.redeemed')); + void qc.invalidateQueries(); + onClose(); + }, + onError: (e) => toast(errText(t, e), 'error'), + }), + ); + const amount = Number(f.amount) || 0; + const num = (v: string) => v.replace(/\D/g, '').slice(0, 9); + return ( + + + + + } + > +
    + + setF({ ...f, orderAmount: num(e.target.value) })} /> + + + setF({ ...f, orderRef: e.target.value })} /> + +
    + + setF({ ...f, amount: num(e.target.value) })} /> + + + setF({ ...f, reason: e.target.value })} /> + + {f.orderAmount && amount > 0 &&

    {t('pt.toPay', { sum: fmt(Math.max(0, Number(f.orderAmount) - amount)) })}

    } +
    + ); +} + +// ───── запросы клиентов ───── + +function Requests() { + const { t, dateTime } = useT(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const list = useQuery(trpc.bo.points.requests.queryOptions()); + const [sums, setSums] = useState>({}); + const [rejecting, setRejecting] = useState(null); + const [reason, setReason] = useState(''); + const dict = useDict(); + const share = dict.data?.rules.sharePct ?? 30; + const done = () => void qc.invalidateQueries(); + const confirm = useMutation(trpc.bo.points.confirm.mutationOptions({ onSuccess: (r) => (toast(t('pt.confirmed', { n: fmt(r.amount) })), done()), onError: (e) => toast(errText(t, e), 'error') })); + const reject = useMutation(trpc.bo.points.reject.mutationOptions({ onSuccess: () => (setRejecting(null), setReason(''), done()), onError: (e) => toast(errText(t, e), 'error') })); + if (!list.data) return ; + if (!list.data.length) return
    {t('pt.noRequests')}
    ; + return ( +
    +
      + {list.data.map((r) => { + const orderSum = sums[r.id] ?? (r.order ? String(r.order.totalPrice - r.order.pointsUsed) : ''); + const max = orderSum ? Math.min(r.amount, r.balance, Math.floor((Number(orderSum) * share) / 100)) : 0; + return ( +
    • + + + {r.name} + {' '} + · {t('pt.wants', { n: fmt(r.amount) })} + + {r.order ? t('pt.forOrder', { n: r.order.number }) : r.note ? `💬 ${r.note}` : t('pt.noOrder')} · {t('pt.balanceN', { n: fmt(r.balance) })} · {dateTime(r.requestedAt)} + + + setSums({ ...sums, [r.id]: e.target.value.replace(/\D/g, '') })} /> + + +
    • + ); + })} +
    + {rejecting && ( + setRejecting(null)} + title={t('pt.reject')} + footer={ + + } + > + + setReason(e.target.value)} /> + + + )} +
    + ); +} + +// ───── предложения системы ───── + +function Suggestions() { + const { t, date } = useT(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const list = useQuery(trpc.bo.points.suggestions.queryOptions()); + const [edit, setEdit] = useState<{ id: string; amount: string; reason: string; mode: 'amount' | 'reject' } | null>(null); + const done = () => (setEdit(null), void qc.invalidateQueries()); + const accept = useMutation(trpc.bo.points.acceptSuggestion.mutationOptions({ onSuccess: () => (toast(t('pt.accrued', { n: '' })), done()), onError: (e) => toast(errText(t, e), 'error') })); + const reject = useMutation(trpc.bo.points.rejectSuggestion.mutationOptions({ onSuccess: done, onError: (e) => toast(errText(t, e), 'error') })); + if (!list.data) return ; + if (!list.data.length) return
    {t('pt.noSuggestions')}
    ; + const why = (s: (typeof list.data)[number]) => { + const p = s.payload as Record; + if (s.mechanic === 'over_limit') return t('pt.why.over_limit', { by: s.by ?? '—', op: t(`pt.op.${String(p.op)}`) }); + if (s.mechanic === 'photo_review') return t('pt.why.photo_review', { n: String(p.orderNumber ?? '') }); + return t(`pt.why.${s.mechanic}`); + }; + return ( +
    +

    + {t('pt.suggestionsHint')} +

    +
      + {list.data.map((s) => ( +
    • + + + {s.name} + {' '} + · {t(`pt.mech.${s.mechanic}`)} + + {why(s)} · {date(s.createdAt)} + + + {fmt(s.amount)} + + {s.mechanic !== 'over_limit' && ( + + )} + +
    • + ))} +
    + {edit && ( + setEdit(null)} + title={edit.mode === 'reject' ? t('pt.reject') : t('pt.changeAmount')} + footer={ + + } + > + {edit.mode === 'amount' && ( + + setEdit({ ...edit, amount: e.target.value.replace(/\D/g, '') })} /> + + )} + + setEdit({ ...edit, reason: e.target.value })} /> + + + )} +
    + ); +} + +// ───── журнал ───── + +function Ledger({ dict, initialClient }: { dict: Dict; initialClient: string | null }) { + const { t, dateTime, locale } = useT(); + const trpc = useTRPC(); + const [f, setF] = useState({ type: '', categoryId: '', channel: '', from: '', to: '' }); + const [rev, setRev] = useState<{ type: 'ACCRUAL' | 'REDEMPTION'; id: string } | null>(null); + const input = useMemo( + () => ({ + clientId: initialClient ?? undefined, + type: (f.type || undefined) as 'accrual' | 'bonus' | 'redemption' | 'reversal' | undefined, + categoryId: f.categoryId || undefined, + channel: (f.channel || undefined) as 'WEB' | 'BOT' | 'CSV' | 'AUTO' | 'CLIENT' | undefined, + from: f.from ? new Date(`${f.from}T00:00:00+04:00`) : undefined, + to: f.to ? new Date(`${f.to}T24:00:00+04:00`) : undefined, + take: 500, + }), + [f, initialClient], + ); + const list = useQuery(trpc.bo.points.ledger.queryOptions(input)); + const csv = () => { + const head = ['date', 'type', 'client', 'amount', 'suggested', 'status', 'what', 'order', 'orderAmount', 'tier', 'rate', 'author', 'channel', 'reason', 'comment']; + const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`; + const rows = (list.data ?? []).map((r) => [r.at.toISOString(), r.type, r.client, r.amount, r.suggested, r.status, pick(r.what, locale), r.orderRef, r.orderAmount, r.tier, r.ratePct, r.author ?? 'system', r.channel, r.reason, r.comment].map(esc).join(',')); + download(`bariki-${todayKey()}.csv`, '' + [head.join(','), ...rows].join('\n')); + }; + return ( +
    +
    + + + + setF({ ...f, from: e.target.value })} /> + setF({ ...f, to: e.target.value })} /> + + {initialClient && ( + + {t('pt.allClients')} + + )} +
    + {!list.data ? ( + + ) : list.data.length === 0 ? ( +

    {t('pt.noOps')}

    + ) : ( +
    + + + + + + + + + + + + + {list.data.map((r) => ( + + + + + + + + + + ))} + +
    {t('pt.col.date')}{t('pt.col.client')}{t('pt.col.what')}{t('pt.col.amount')}{t('pt.col.status')}{t('pt.col.author')} +
    {dateTime(r.at)} + {r.client} + + {r.type === 'accrual' || r.type === 'bonus' ? pick(r.what, locale) : t(`pt.type.${r.type}`)} + {r.orderRef ? ` ${r.orderRef}` : ''} + {r.orderAmount ? ` · ${fmt(r.orderAmount)} ֏` : ''} + {r.tier ? ` · ${TIER[r.tier]} ${r.ratePct}%` : ''} + {(r.reason || r.comment) && ( + + {[r.reason, r.comment?.startsWith('debt:') ? t('pt.debtN', { n: fmt(Number(r.comment.slice(5))) }) : r.comment].filter(Boolean).join(' · ')} + + )} + + + {r.amount > 0 ? '+' : '−'} + {fmt(Math.abs(r.amount))} + + {r.suggested != null && r.suggested !== r.amount && {t('pt.suggestedN', { n: fmt(r.suggested) })}} + {t(`pt.status.${r.status}`)} + {r.author ?? t('pt.system')} + + {t(`pt.channel.${r.channel}`)} + + + {r.canReverse && ( + + )} +
    +
    + )} + {rev && setRev(null)} />} +
    + ); +} + +// ───── отчёт ───── + +function Report() { + const { t, locale } = useT(); + const trpc = useTRPC(); + const monthAgo = new Date(Date.now() - 30 * 86_400_000 + 4 * 3600_000).toISOString().slice(0, 10); + const [p, setP] = useState({ from: monthAgo, to: todayKey() }); + const r = useQuery(trpc.bo.points.report.queryOptions({ from: new Date(`${p.from}T00:00:00+04:00`), to: new Date(`${p.to}T24:00:00+04:00`) })); + return ( + <> +
    + setP({ ...p, from: e.target.value })} /> + setP({ ...p, to: e.target.value })} /> +
    + {!r.data ? ( + + ) : ( +
    +
    +
    +
    + {(['accrued', 'spent', 'expired', 'reversed', 'liabilities'] as const).map((k) => ( + + {t(`pt.rep.${k}`)} + {fmt(r.data[k])} + + ))} + + {t('pt.rep.rate')} + {r.data.redemptionRate}% + +
    +
    +
    +
    +

    {t('pt.rep.byCategory')}

    +
    +
      + {r.data.byCategory.map((c) => ( +
    • + {pick(c.name, locale) || c.code} + {t('pt.rep.cost', { pct: c.costPct, revenue: fmt(c.revenue) })} + {fmt(c.points)} +
    • + ))} +
    +
    +
    +
    +

    {t('pt.rep.retention')}

    +
    +
      + {r.data.retention.map((x) => ( +
    • + {t(x.withPoints ? 'pt.rep.with' : 'pt.rep.without')} + {t('pt.rep.clientsN', { n: x.clients, multi: x.multi })} + {x.repeatPct}% +
    • + ))} +
    +
    +
    +
    +
    +
    +

    {t('pt.rep.topClients')}

    +
    +
      + {r.data.topClients.map((c) => ( +
    • + + {c.name} + + {fmt(c.balance)} +
    • + ))} +
    +
    +
    +
    +

    {t('pt.rep.topAgencies')}

    +
    + {r.data.topAgencies.length === 0 ? ( +

    —

    + ) : ( +
      + {r.data.topAgencies.map((a) => ( +
    • + {a.name} + {a.n} +
    • + ))} +
    + )} +
    +
    +
    + )} + + ); +} + +// ───── импорт CSV ───── + +function parseCsv(text: string) { + const lines = text.replace(/\r/g, '').split('\n').filter((l) => l.trim()); + const sep = lines[0]?.includes(';') ? ';' : ','; + const rows = lines.map((l) => l.split(sep).map((c) => c.trim().replace(/^"|"$/g, ''))); + // первая строка — заголовок, если в третьей колонке не число + const body = rows[0] && Number.isNaN(Number(rows[0][2]?.replace(/\s/g, ''))) ? rows.slice(1) : rows; + return body.map((r) => ({ phone: r[0] ?? '', category: r[1] ?? '', orderAmount: Number((r[2] ?? '').replace(/[^\d]/g, '')) || 0, date: r[3] ?? '', orderRef: r[4] ?? '' })); +} + +function Import() { + const { t } = useT(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const [rows, setRows] = useState>([]); + const run = useMutation(trpc.bo.points.import.mutationOptions({ onError: (e) => toast(errText(t, e), 'error') })); + const preview = run.data && run.variables?.dryRun ? run.data : null; + const commit = useMutation( + trpc.bo.points.import.mutationOptions({ + onSuccess: (r) => { + toast(t('pt.importDone', { n: r.rows.filter((x) => !x.error).length })); + setRows([]); + run.reset(); + void qc.invalidateQueries(); + }, + onError: (e) => toast(errText(t, e), 'error'), + }), + ); + return ( +
    +

    {t('pt.importHint')}

    + { + const file = e.target.files?.[0]; + if (!file) return; + const parsed = parseCsv(await file.text()); + setRows(parsed); + run.mutate({ dryRun: true, rows: parsed }); + }} + /> + {preview && ( + <> +
    + + {t('pt.importRows')} + {preview.rows.length} + + + {t('pt.importTotal')} + {fmt(preview.total)} + +
    +
      + {preview.rows.map((r) => ( +
    • + {r.line} + {r.client ?? rows[r.line - 1]?.phone} + {r.error ? {t(`pt.importErr.${r.error}`)} : +{fmt(r.amount ?? 0)}} +
    • + ))} +
    + + + )} +
    + ); +} + +// ───── справочники ───── + +function Setup({ dict }: { dict: Dict }) { + const { t, locale } = useT(); + const [cat, setCat] = useState(null); + const [bt, setBt] = useState(null); + return ( +
    +
    +
    +

    {t('pt.categories')}

    + +
    + + + + + + + + + + {dict.categories.map((c) => ( + setCat(c)} style={{ cursor: 'pointer' }} data-off={!c.active || undefined}> + + + + + + ))} + +
    + BarevBarev+Elite
    {pick(c.name, locale)}{c.rateBarev}%{c.ratePlus}%{c.rateElite}%
    +

    + {t('pt.rulesHint', { hours: dict.rules.pendingHours, share: dict.rules.sharePct, min: fmt(dict.rules.min), burn: dict.rules.burnDays })} +

    +
    +
    +
    +

    {t('pt.bonusTypes')}

    + +
    +
      + {dict.bonusTypes.map((b) => ( +
    • setBt(b)} style={{ cursor: 'pointer', opacity: b.active ? 1 : 0.5 }}> + + {pick(b.name, locale)} + + {b.ttlDays ? t('pt.forDays', { n: b.ttlDays }) : t('pt.noTtl')} + + + {fmt(b.defaultAmount)} +
    • + ))} +
    +
    + {cat && setCat(null)} />} + {bt && setBt(null)} />} +
    + ); +} + +const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const; +type L5 = Record<(typeof LANGS)[number], string>; +const l5 = (v?: Record): L5 => ({ ru: v?.ru ?? '', en: v?.en ?? '', hy: v?.hy ?? '', zh: v?.zh ?? '', es: v?.es ?? '' }); + +function NameFields({ value, onChange }: { value: L5; onChange: (v: L5) => void }) { + return ( +
    + {LANGS.map((l) => ( + + onChange({ ...value, [l]: e.target.value })} /> + + ))} +
    + ); +} + +function CategoryDialog({ row, onClose }: { row: Dict['categories'][number] | null; onClose: () => void }) { + const { t } = useT(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const [f, setF] = useState({ code: row?.code ?? '', name: l5(row?.name), rateBarev: String(row?.rateBarev ?? 3), ratePlus: String(row?.ratePlus ?? 5), rateElite: String(row?.rateElite ?? 7), active: row?.active ?? true }); + const save = useMutation(trpc.bo.points.saveCategory.mutationOptions({ onSuccess: () => (void qc.invalidateQueries(), onClose()), onError: (e) => toast(errText(t, e), 'error') })); + return ( + save.mutate({ id: row?.id, code: f.code, name: f.name, rateBarev: Number(f.rateBarev) || 0, ratePlus: Number(f.ratePlus) || 0, rateElite: Number(f.rateElite) || 0, active: f.active, sortOrder: row?.sortOrder ?? 99 })}> + {t('ui.save')} + + } + > + + setF({ ...f, code: e.target.value.toLowerCase().replace(/[^a-z_]/g, '') })} /> + + setF({ ...f, name })} /> +
    + {(['rateBarev', 'ratePlus', 'rateElite'] as const).map((k) => ( + + setF({ ...f, [k]: e.target.value.replace(/\D/g, '').slice(0, 3) })} /> + + ))} +
    + +
    + ); +} + +function BonusDialog({ row, onClose }: { row: Dict['bonusTypes'][number] | null; onClose: () => void }) { + const { t } = useT(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const [f, setF] = useState({ code: row?.code ?? '', name: l5(row?.name), defaultAmount: String(row?.defaultAmount ?? 1000), ttlDays: row?.ttlDays ? String(row.ttlDays) : '', active: row?.active ?? true }); + const save = useMutation(trpc.bo.points.saveBonusType.mutationOptions({ onSuccess: () => (void qc.invalidateQueries(), onClose()), onError: (e) => toast(errText(t, e), 'error') })); + return ( + save.mutate({ id: row?.id, code: f.code, name: f.name, defaultAmount: Number(f.defaultAmount) || 0, ttlDays: Number(f.ttlDays) || null, active: f.active, sortOrder: row?.sortOrder ?? 99 })}> + {t('ui.save')} + + } + > + + setF({ ...f, code: e.target.value.toLowerCase().replace(/[^a-z_]/g, '') })} /> + + setF({ ...f, name })} /> +
    + + setF({ ...f, defaultAmount: e.target.value.replace(/\D/g, '') })} /> + + + setF({ ...f, ttlDays: e.target.value.replace(/\D/g, '').slice(0, 4) })} /> + +
    + +
    + ); +} diff --git a/apps/backoffice/src/pages/ProviderCard.tsx b/apps/backoffice/src/pages/ProviderCard.tsx index 12e1d34..da52d07 100644 --- a/apps/backoffice/src/pages/ProviderCard.tsx +++ b/apps/backoffice/src/pages/ProviderCard.tsx @@ -299,8 +299,10 @@ export function ProviderCard({ id, me }: { id: string; me: Me }) { ); } -function rateText(t: ReturnType['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number; name?: string }) { +function rateText(t: ReturnType['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number | null; name?: string }) { if (r.kind === 'preset') return t('prv.ratePreset', { name: r.name ?? '' }); + // сотрудник партнёра «как Uber» — зарплату ему платит партнёр + if (r.kind === 'company' && r.value == null) return t('prv.ratePartnerPays'); const v = r.type === 'PERCENT' ? `${r.value}%` : `${r.value} ֏`; return r.kind === 'company' ? t('prv.rateCompany', { v }) : t('prv.rateOwn', { v }); } diff --git a/apps/backoffice/src/pages/ServicePartners.tsx b/apps/backoffice/src/pages/ServicePartners.tsx new file mode 100644 index 0000000..c7cda93 --- /dev/null +++ b/apps/backoffice/src/pages/ServicePartners.tsx @@ -0,0 +1,485 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { formatDateTime, type Locale } from '@barev/i18n'; +import { navigate, useToast, useTRPC } from '@barev/web'; +import { useState } from 'react'; +import { errText, useT } from '../i18n.tsx'; +import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx'; +import { CopyLink } from './Agencies.tsx'; + +type Deal = 'PROFIT_SHARE' | 'COMMISSION'; +type Terms = { deal: Deal; commissionPct: number; profitSharePct: number; fotPct: number; taxPct: number }; + +function useTermsText() { + const { t } = useT(); + return (x: Terms) => (x.deal === 'COMMISSION' ? t('sp.terms.COMMISSION', { pct: x.commissionPct }) : t('sp.terms.PROFIT_SHARE', { pct: x.profitSharePct, fot: x.fotPct, tax: x.taxPct })); +} + +/** Партнёры услуг: клининговые компании со своими сотрудниками и менеджером */ +export function ServicePartners() { + const { t, amd } = useT(); + const trpc = useTRPC(); + const terms = useTermsText(); + const list = useQuery(trpc.bo.partners.servicePartners.queryOptions()); + const [creating, setCreating] = useState(false); + return ( + <> +
    +

    {t('sp.title')}

    + +
    +

    + {t('sp.lead')} +

    + {!list.data ? ( + + ) : list.data.length === 0 ? ( +
    {t('sp.empty')}
    + ) : ( +
    + + + + + + + + + + + + {list.data.map((c) => ( + navigate(`/service-partners/${c.id}`)} data-off={!c.active || undefined}> + + + + + + + ))} + +
    {t('sp.col.name')}{t('sp.col.staff')}{t('sp.col.active')}{t('sp.col.month')}{t('sp.col.balance')}
    + e.stopPropagation()}> + + + {c.name} + {terms(c.terms)} + + {!c.active && {t('ag.inactive')}} + {c.managers === 0 && {t('sp.noManager')}} + {c.autoAssign && {t('sp.auto')}} + + + + {c.staff}{c.activeOrders}{amd(c.thisMonth)} + {amd(c.balance)} +
    +
    + )} + {creating && setCreating(false)} />} + + ); +} + +export function ServicePartnerCard({ id }: { id: string }) { + const { t, amd, date, dateTime, locale, shared } = useT(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const terms = useTermsText(); + const c = useQuery(trpc.bo.partners.servicePartner.queryOptions({ id })); + const [dlg, setDlg] = useState<'edit' | 'payout' | null>(null); + const refresh = () => { + void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartner.queryKey({ id }) }); + void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartners.queryKey() }); + }; + const reset = useMutation(trpc.bo.partners.resetManagerLink.mutationOptions({ onSuccess: () => (refresh(), toast(t('sp.linkReset'))), onError: (e) => toast(errText(t, e), 'error') })); + const removeManager = useMutation(trpc.bo.partners.removeManager.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') })); + const back = ( + + + {t('sp.back')} + + ); + if (!c.data) + return ( + <> + {back} + {c.isError ?
    {errText(t, c.error)}
    : } + + ); + const d = c.data; + return ( + <> + {back} +
    + +
    +

    + {d.name} +

    +
    + {terms(d.terms)} + {d.contactPhone && {d.contactPhone}} + {t('prv.since', { date: date(d.createdAt) })} + {d.autoAssign && {t('sp.auto')}} + {!d.active && {t('ag.inactive')}} +
    +
    + +
    + +
    +
    +
    +
    +

    {t('sp.managerLink')}

    + +
    +

    + {t('sp.managerLinkHint')} +

    + +

    + {t('sp.managers')} +

    + {d.managers.length === 0 ? ( +

    {t('sp.noManagers')}

    + ) : ( +
      + {d.managers.map((m) => ( +
    • + + {m.name} + + {t('sp.managerSince', { date: date(m.since) })} + + + +
    • + ))} +
    + )} +
    + +
    +
    +

    {t('sp.staff')}

    + {t('sp.staffHint')} +
    + {d.staff.length === 0 ? ( +

    {t('sp.noStaff')}

    + ) : ( + + )} +
    + +
    +
    +

    {t('sp.orders')}

    +
    + {d.orders.length === 0 ? ( +

    {t('sp.noOrders')}

    + ) : ( + + )} +
    +
    + +
    +
    + + {t('sp.toPay')} + +
    + {amd(d.money.balance)} +
    +
    + + {t('ag.accrued')} {amd(d.money.accrued)} + + + {t('prv.paidTotal')} {amd(d.money.paid)} + +
    + + {d.money.months.length > 0 && ( +
      + {d.money.months.map((m) => ( +
    • + + {formatDateTime(locale as Locale, new Date(`${m.period}-15T12:00:00+04:00`), { month: 'long', year: 'numeric' })} + + + {t('ag.orders', { n: m.orders })} · {amd(m.revenue)} + + {amd(m.amount)} +
    • + ))} +
    + )} +
    +
    +
    +

    {t('sp.byOrder')}

    +
    + {d.money.recent.length === 0 ? ( +

    {t('sp.noIncome')}

    + ) : ( + + )} +
    + {d.note && ( +
    +

    + {d.note} +

    +
    + )} +
    +
    + {dlg === 'edit' && setDlg(null)} onDone={refresh} />} + {dlg === 'payout' && setDlg(null)} onDone={refresh} />} + + ); +} + +type PartnerRow = { + id: string; + name: string; + contactPhone: string | null; + note: string | null; + active: boolean; + deal: Deal; + commissionPct: number; + profitSharePct: number; + fotPct: number | null; + taxPct: number | null; + defaults: { fotPct: number; taxPct: number }; +}; + +function PartnerDialog({ partner, onClose, onDone }: { partner?: PartnerRow; onClose: () => void; onDone?: () => void }) { + const { t, amd } = useT(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const settings = useQuery({ ...trpc.bo.settings.get.queryOptions(), enabled: !partner }); + const defaults = partner?.defaults ?? { fotPct: settings.data?.['partner.fotPct'] ?? 50, taxPct: settings.data?.['partner.taxPct'] ?? 10 }; + const [f, setF] = useState({ + name: partner?.name ?? '', + contactPhone: partner?.contactPhone ?? '', + note: partner?.note ?? '', + active: partner?.active ?? true, + deal: partner?.deal ?? ('COMMISSION' as Deal), + commissionPct: String(partner?.commissionPct ?? 30), + profitSharePct: String(partner?.profitSharePct ?? 50), + fotPct: partner?.fotPct == null ? '' : String(partner.fotPct), + taxPct: partner?.taxPct == null ? '' : String(partner.taxPct), + }); + const num = (s: string) => Math.min(100, Number(s) || 0); + const opt = (s: string) => (s === '' ? null : num(s)); + // пример на 20 000 ֏, чтобы было видно, как считается + const ex = 20_000; + const fot = opt(f.fotPct) ?? defaults.fotPct; + const tax = opt(f.taxPct) ?? defaults.taxPct; + const example = + f.deal === 'COMMISSION' + ? t('sp.exampleCOMMISSION', { sum: amd(ex), ours: amd(Math.floor((ex * num(f.commissionPct)) / 100)), partner: amd(ex - Math.floor((ex * num(f.commissionPct)) / 100)) }) + : (() => { + const fotS = Math.floor((ex * fot) / 100); + const taxS = Math.floor((ex * tax) / 100); + const profit = Math.max(0, ex - fotS - taxS); + const p = Math.floor((profit * num(f.profitSharePct)) / 100); + return t('sp.examplePROFIT_SHARE', { sum: amd(ex), fot: amd(fotS), tax: amd(taxS), profit: amd(profit), partner: amd(p), ours: amd(profit - p) }); + })(); + const save = useMutation( + trpc.bo.partners.saveServicePartner.mutationOptions({ + onSuccess: (r) => { + void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartners.queryKey() }); + onDone?.(); + onClose(); + if (!partner) navigate(`/service-partners/${r.id}`); + }, + onError: (e) => toast(errText(t, e), 'error'), + }), + ); + return ( + + + + + } + > +
    + + setF({ ...f, name: e.target.value })} /> + + + setF({ ...f, contactPhone: e.target.value })} /> + +
    + + + + +
    + {(['COMMISSION', 'PROFIT_SHARE'] as const).map((d) => ( + + ))} +
    +
    +

    + {t(`sp.dealHint.${f.deal}`)} +

    + {f.deal === 'COMMISSION' ? ( + + setF({ ...f, commissionPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} /> + + ) : ( + <> + + setF({ ...f, profitSharePct: e.target.value.replace(/\D/g, '').slice(0, 3) })} /> + +
    + + setF({ ...f, fotPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} /> + + + setF({ ...f, taxPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} /> + +
    + + )} +

    + {example} +

    + +