Bariki cashback, order edit, and current work in progress
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dda917fc71
commit
45477fb018
94 files changed
+10955
-650
No files matched your search
@@ -72,7 +72,7 @@ export function WebRequests() {
|
||||
<b>{r.number}</b>
|
||||
<div className="bc-small bc-muted">{t('admin.webRequests.created', { date: dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) })}</div>
|
||||
</td>
|
||||
<td className="bc-small">{dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</td>
|
||||
<td className="bc-small">{r.preferredAt ? dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : '—'}</td>
|
||||
<td>
|
||||
<b>{t(`params.cleaning.cleaningType.${p.cleaningType ?? 'regular'}`)}</b>
|
||||
<div className="bc-small">
|
||||
@@ -83,8 +83,8 @@ export function WebRequests() {
|
||||
)}
|
||||
</td>
|
||||
<td className="bc-small">
|
||||
{t(`districts.${r.district.code}`)}
|
||||
<div>{r.addressText}</div>
|
||||
{r.district ? t(`districts.${r.district.code}`) : '—'}
|
||||
<div>{r.addressText ?? '—'}</div>
|
||||
{r.note && <div className="bc-muted">{r.note}</div>}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = <T extends { companyId: string | null }>(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 }) => {
|
||||
|
||||
@@ -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<string, string> })),
|
||||
bonusTypes: bonusTypes.map((b) => ({ ...b, name: b.name as Record<string, string> })),
|
||||
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<string, string>();
|
||||
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<string, string>) ?? null) : ((a.category?.name as Record<string, string>) ?? 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<string>();
|
||||
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<string, string>).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<string, unknown>;
|
||||
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<string, string>; 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 })),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 })),
|
||||
}),
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
+24
-1
@@ -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' });
|
||||
|
||||
+11
-2
@@ -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 }) => {
|
||||
|
||||
@@ -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 = <LeadCard id={leadId} me={me} />;
|
||||
else if (path === '/agencies' && isSuper) page = <Agencies />;
|
||||
else if (agencyId && isSuper) page = <AgencyCard id={agencyId} />;
|
||||
else if (path === '/points' && isSuper) page = <Points />;
|
||||
else if (path === '/service-partners' && isSuper) page = <ServicePartners />;
|
||||
else if (servicePartnerId && isSuper) page = <ServicePartnerCard id={servicePartnerId} />;
|
||||
else if (path === '/venues' && isSuper) page = <Venues />;
|
||||
else if (venueId && isSuper) page = <VenueCard id={venueId} />;
|
||||
else if (path === '/products' && canProducts) page = <Products me={me} />;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+851
-32
@@ -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<BoLocale, Dict> = { ru, en, hy };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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<typeof useT>['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() {
|
||||
<Avatar name={a.name} id={a.id} size={38} />
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span className="bo-who__name">{a.name}</span>
|
||||
<span className="bo-who__meta">{[a.code, `${a.commissionPct}%`, a.sales?.name].filter(Boolean).join(' · ')}</span>
|
||||
<span className="bo-who__meta">{[a.code, `${a.commissionPct}%`, giftText(t, a), a.sales?.name].filter(Boolean).join(' · ')}</span>
|
||||
{!a.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
|
||||
</span>
|
||||
</a>
|
||||
@@ -125,6 +134,8 @@ export function AgencyCard({ id }: { id: string }) {
|
||||
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
|
||||
<span className="bo-num">{d.code}</span>
|
||||
<span>{t('ag.pct', { n: d.commissionPct })}</span>
|
||||
<span>🎁 {giftText(t, d)}</span>
|
||||
{d.housewarmingPoints > 0 && <span>🪙 {t('ag.hwValue', { n: new Intl.NumberFormat('ru-RU').format(d.housewarmingPoints), days: d.housewarmingDays })}</span>}
|
||||
{d.sales && <span>{t('ag.byS', { name: d.sales.name })}</span>}
|
||||
<span>{t('prv.since', { date: date(d.createdAt) })}</span>
|
||||
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
|
||||
@@ -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')}
|
||||
</button>
|
||||
@@ -297,6 +312,32 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('ag.giftPlan')} hint={t('ag.giftHint')}>
|
||||
<select className="bo-select" value={f.giftPlan} onChange={(e) => setF({ ...f, giftPlan: e.target.value as typeof f.giftPlan })}>
|
||||
<option value="BAREV_PLUS">Barev+</option>
|
||||
<option value="ELITE">Barev Elite</option>
|
||||
<option value="NONE">{t('ag.noGift')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('ag.giftMonths')}>
|
||||
<select className="bo-select" value={f.giftMonths} disabled={f.giftPlan === 'NONE'} onChange={(e) => setF({ ...f, giftMonths: Number(e.target.value) as GiftMonths })}>
|
||||
{GIFT_MONTHS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{t('ag.monthsN', { n: m })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('ag.hwPoints')} hint={t('ag.hwHint')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.hwPoints} onChange={(e) => setF({ ...f, hwPoints: e.target.value.replace(/\D/g, '').slice(0, 7) })} />
|
||||
</Field>
|
||||
<Field label={t('ag.hwDays')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.hwDays} disabled={!Number(f.hwPoints)} onChange={(e) => setF({ ...f, hwDays: e.target.value.replace(/\D/g, '').slice(0, 4) })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('crm.contacts')}>
|
||||
<input className="bo-input" value={f.contact} maxLength={200} onChange={(e) => setF({ ...f, contact: e.target.value })} placeholder={t('ag.contactPh')} />
|
||||
</Field>
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
|
||||
<div className="bo-col">
|
||||
{isSuper && <ClientPoints id={d.id} compact />}
|
||||
{d.money && (
|
||||
<section className="bo-panel bo-money-card">
|
||||
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
|
||||
|
||||
@@ -519,14 +519,17 @@ export function Requests({ me }: { me: Me }) {
|
||||
<b>
|
||||
<span className="bo-num bo-faint">№{r.number}</span> {shared(`services.${r.service.code}.name`)}
|
||||
</b>
|
||||
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
|
||||
<span className="bo-tags">
|
||||
{r.status !== 'ORDERED' && r.status !== 'CANCELLED' && (!r.preferredAt || !r.district || !r.addressText) && <span className="bo-chip bo-chip--warn">{t('rq.missing')}</span>}
|
||||
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bo-small">{paramsSummary(sharedT, r.service.code, r.params as Record<string, unknown>)}</div>
|
||||
<div className="bo-small">
|
||||
{t('rq.wish')}: <b>{dateTime(r.preferredAt)}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
|
||||
{t('rq.wish')}: <b>{r.preferredAt ? dateTime(r.preferredAt) : t('rq.noTime')}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
|
||||
</div>
|
||||
<div className="bo-small bo-faint">
|
||||
{shared(`districts.${r.district.code}`)}, {r.addressText}
|
||||
{[r.district ? shared(`districts.${r.district.code}`) : t('rq.noDistrict'), r.addressText ?? t('rq.noAddress')].join(', ')}
|
||||
</div>
|
||||
<div className="bo-rq__who">
|
||||
<span>
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 }) {
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('ord.details')}</h2>
|
||||
{d.status !== 'CANCELLED' && (
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => setDlg('edit')}>
|
||||
{t('ord.editComposition')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<dl className="bo-kv">
|
||||
<dt>{t('ord.when')}</dt>
|
||||
@@ -143,9 +150,19 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
|
||||
<ul className="bo-checks">
|
||||
{d.checklist.map((c) => (
|
||||
<li key={c.code} data-done={c.done || undefined}>
|
||||
<span className="bo-checks__box">{c.done && <I name="check" size={14} stroke={3} />}</span>
|
||||
{shared(`checklist.${c.code}`)}
|
||||
{c.requiresPhoto && !c.fileId && <span className="bo-faint bo-small"> · {t('ord.needsPhoto')}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="bo-checks__toggle"
|
||||
disabled={setChecklist.isPending || d.status === 'CANCELLED'}
|
||||
title={c.done ? t('ord.uncheck') : t('ord.checkForProvider')}
|
||||
onClick={() => (c.done ? setChecklist.mutate({ id: d.id, code: c.code, checked: false }) : c.requiresPhoto && !c.fileId ? setCheck(c) : setChecklist.mutate({ id: d.id, code: c.code, checked: true }))}
|
||||
>
|
||||
<span className="bo-checks__box">{c.done && <I name="check" size={14} stroke={3} />}</span>
|
||||
{shared(`checklist.${c.code}`)}
|
||||
</button>
|
||||
{c.requiresPhoto && !c.fileId && !c.waived && <span className="bo-faint bo-small"> · {t('ord.needsPhoto')}</span>}
|
||||
{c.waived && <span className="bo-faint bo-small"> · {t('ord.noPhotoReason', { reason: c.waived })}</span>}
|
||||
{c.byStaff && <span className="bo-faint bo-small"> · {t('ord.byStaff')}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -332,6 +349,8 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
|
||||
</div>
|
||||
|
||||
{dlg === 'reschedule' && <RescheduleDialog order={d} onClose={() => setDlg(null)} onDone={refresh} />}
|
||||
{dlg === 'edit' && <EditDialog order={d} onClose={() => setDlg(null)} onDone={refresh} />}
|
||||
{check && <ChecklistPhotoDialog orderId={d.id} code={check.code} onClose={() => setCheck(null)} onDone={refresh} />}
|
||||
{dlg === 'cancel' && <CancelDialog id={id} showFee={!!d.money} onClose={() => 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
|
||||
) : (
|
||||
<ul className="bo-cands">
|
||||
{cands.data.free.map((c, i) => (
|
||||
<li key={`${c.kind}:${c.id}`}>
|
||||
<li key={`${c.kind}:${c.id}`} data-best={c.best || undefined}>
|
||||
<span className="bo-cands__rank">{i + 1}</span>
|
||||
<span className="bo-grow" style={{ minWidth: 0 }}>
|
||||
<b>{c.name}</b>
|
||||
{c.kind === 'company' && <span className="bo-chip" style={{ marginLeft: 6 }}>{t('ord.company')}</span>}
|
||||
{c.kind === 'company' && <span className="bo-chip" style={{ marginLeft: 6 }}>{t('ord.partner')}</span>}
|
||||
{c.best && <span className="bo-chip bo-chip--ok" style={{ marginLeft: 6 }}>{t('ord.best')}</span>}
|
||||
{c.tried && <span className="bo-chip bo-chip--warn" style={{ marginLeft: 6 }}>{t('ord.tried')}</span>}
|
||||
<span className="bo-small bo-muted" style={{ display: 'block' }}>
|
||||
{[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(' · ')}
|
||||
</span>
|
||||
{c.margin != null && (
|
||||
<span className="bo-small" style={{ display: 'block' }}>
|
||||
{t('ord.candMoney', { cost: amd(c.cost ?? 0), margin: amd(c.margin) })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button type="button" className="bo-btn bo-btn--sm" disabled={assign.isPending} onClick={() => assign.mutate({ id: d.id, target: { kind: c.kind, id: c.id } })}>
|
||||
{t('ord.offer')}
|
||||
@@ -610,3 +642,132 @@ function CancelDialog({ id, showFee, onClose, onDone }: { id: string; showFee: b
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Изменить состав заказа: пересчёт по прайсу с прежней скидкой или своя цена с причиной */
|
||||
function EditDialog({ order, onClose, onDone }: { order: { id: string; number: number; service: string; params: Record<string, unknown> }; onClose: () => void; onDone: () => void }) {
|
||||
const { t, amd } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const services = useQuery(trpc.bo.orders.services.queryOptions());
|
||||
const fields = (services.data?.find((s) => s.code === order.service)?.paramsSchema.fields ?? []) as ParamField[];
|
||||
const [params, setParams] = useState<Record<string, unknown>>(() => ({ ...order.params }));
|
||||
const [price, setPrice] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [notify, setNotify] = useState(true);
|
||||
const p = useQuery({ ...trpc.bo.orders.editPreview.queryOptions({ id: order.id, params }), placeholderData: (prev) => prev });
|
||||
const manual = price !== '' && p.data && Number(price) !== p.data.totalPrice;
|
||||
const save = useMutation(
|
||||
trpc.bo.orders.edit.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
toast(r.providerBusy ? t('ord.editedBusy') : t('ord.edited'));
|
||||
onDone();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
wide
|
||||
onClose={onClose}
|
||||
title={t('ord.editTitle', { n: order.number })}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!p.data || save.isPending || (!!manual && !reason.trim())} onClick={() => save.mutate({ id: order.id, params, totalPrice: price === '' ? null : Number(price), reason: reason.trim() || null, notify })}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{fields.length > 0 && <ParamsForm service={order.service} fields={fields} value={params} onChange={setParams} />}
|
||||
{p.isError && <p className="bo-hint" style={{ color: 'var(--bc-danger)' }}>{errText(t, p.error)}</p>}
|
||||
{p.data && (
|
||||
<div className="bo-side-panel__note" style={{ margin: '14px 0' }}>
|
||||
{t('ord.editPrice', { was: amd(p.data.was.totalPrice), now: amd(p.data.totalPrice), pct: p.data.discountPct })}
|
||||
<br />
|
||||
{t('ord.editDuration', { was: p.data.was.durationMinutes, now: p.data.durationMinutes })}
|
||||
{p.data.providerBusy && (
|
||||
<>
|
||||
<br />⚠️ {t('ord.editBusy')}
|
||||
</>
|
||||
)}
|
||||
{p.data.paid && (
|
||||
<>
|
||||
<br />💳 {t('ord.editPaid')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="bo-row2">
|
||||
<Field label={t('ord.editOwnPrice')} hint={p.data ? t('ord.editOwnPriceHint', { n: amd(p.data.totalPrice) }) : undefined}>
|
||||
<input className="bo-input" inputMode="numeric" value={price} placeholder={p.data ? String(p.data.totalPrice) : ''} onChange={(e) => setPrice(e.target.value.replace(/\D/g, '').slice(0, 8))} />
|
||||
</Field>
|
||||
<Field label={manual ? t('ord.editReasonRequired') : t('ord.editReason')}>
|
||||
<input className="bo-input" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.editReasonPh')} />
|
||||
</Field>
|
||||
</div>
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} />
|
||||
{t('ord.editNotify')}
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Пункт с фото: загрузить фото или отметить без фото с причиной */
|
||||
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 (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={shared(`checklist.${code}`)}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!reason.trim() || mark.isPending} onClick={() => mark.mutate({ id: orderId, code, checked: true, reason })}>
|
||||
{t('ord.checkNoPhoto')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="bo-hint" style={{ marginTop: 0 }}>
|
||||
{t('ord.photoHint')}
|
||||
</p>
|
||||
<label className="bo-btn bo-btn--accent" style={{ display: 'inline-flex', marginBottom: 14, opacity: busy ? 0.6 : 1 }}>
|
||||
<I name="clip" size={16} />
|
||||
{busy ? t('ui.loading') : t('ord.uploadPhoto')}
|
||||
<input type="file" accept="image/*" hidden disabled={busy} onChange={(e) => e.target.files?.[0] && void upload(e.target.files[0])} />
|
||||
</label>
|
||||
<Field label={t('ord.noPhotoField')}>
|
||||
<input className="bo-input" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.noPhotoPh')} />
|
||||
</Field>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -299,8 +299,10 @@ export function ProviderCard({ id, me }: { id: string; me: Me }) {
|
||||
);
|
||||
}
|
||||
|
||||
function rateText(t: ReturnType<typeof useT>['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number; name?: string }) {
|
||||
function rateText(t: ReturnType<typeof useT>['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 });
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{t('sp.title')}</h1>
|
||||
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
|
||||
<I name="plus" size={16} />
|
||||
{t('sp.add')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginTop: -6, marginBottom: 14 }}>
|
||||
{t('sp.lead')}
|
||||
</p>
|
||||
{!list.data ? (
|
||||
<Skeleton h={240} />
|
||||
) : list.data.length === 0 ? (
|
||||
<div className="bo-panel bo-empty">{t('sp.empty')}</div>
|
||||
) : (
|
||||
<div className="bo-panel" style={{ padding: 8 }}>
|
||||
<table className="bo-table bo-ptable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('sp.col.name')}</th>
|
||||
<th className="bo-r">{t('sp.col.staff')}</th>
|
||||
<th className="bo-r">{t('sp.col.active')}</th>
|
||||
<th className="bo-r">{t('sp.col.month')}</th>
|
||||
<th className="bo-r">{t('sp.col.balance')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data.map((c) => (
|
||||
<tr key={c.id} onClick={() => navigate(`/service-partners/${c.id}`)} data-off={!c.active || undefined}>
|
||||
<td>
|
||||
<a href={`#/service-partners/${c.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar name={c.name} id={c.id} size={38} />
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span className="bo-who__name">{c.name}</span>
|
||||
<span className="bo-who__meta">{terms(c.terms)}</span>
|
||||
<span className="bo-tags" style={{ marginTop: 4 }}>
|
||||
{!c.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
|
||||
{c.managers === 0 && <span className="bo-chip bo-chip--warn">{t('sp.noManager')}</span>}
|
||||
{c.autoAssign && <span className="bo-chip">{t('sp.auto')}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td className="bo-r bo-num">{c.staff}</td>
|
||||
<td className="bo-r bo-num">{c.activeOrders}</td>
|
||||
<td className="bo-r bo-num">{amd(c.thisMonth)}</td>
|
||||
<td className="bo-r">
|
||||
<b className="bo-num">{amd(c.balance)}</b>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{creating && <PartnerDialog onClose={() => 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 = (
|
||||
<a className="bo-back" href="#/service-partners">
|
||||
<I name="back" size={16} />
|
||||
{t('sp.back')}
|
||||
</a>
|
||||
);
|
||||
if (!c.data)
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
{c.isError ? <div className="bo-panel bo-empty">{errText(t, c.error)}</div> : <Skeleton h={300} />}
|
||||
</>
|
||||
);
|
||||
const d = c.data;
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<header className="bo-profile">
|
||||
<Avatar name={d.name} id={d.id} size={64} />
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
|
||||
{d.name}
|
||||
</h1>
|
||||
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
|
||||
<span>{terms(d.terms)}</span>
|
||||
{d.contactPhone && <span>{d.contactPhone}</span>}
|
||||
<span>{t('prv.since', { date: date(d.createdAt) })}</span>
|
||||
{d.autoAssign && <span className="bo-chip">{t('sp.auto')}</span>}
|
||||
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="bo-btn" onClick={() => setDlg('edit')}>
|
||||
{t('prv.edit')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="bo-client-grid">
|
||||
<div className="bo-col">
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('sp.managerLink')}</h2>
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" disabled={reset.isPending} onClick={() => confirm(t('sp.resetConfirm')) && reset.mutate({ id })}>
|
||||
{t('sp.resetLink')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginBottom: 10 }}>
|
||||
{t('sp.managerLinkHint')}
|
||||
</p>
|
||||
<CopyLink url={d.managerLink} />
|
||||
<h3 className="bo-label" style={{ marginTop: 16 }}>
|
||||
{t('sp.managers')}
|
||||
</h3>
|
||||
{d.managers.length === 0 ? (
|
||||
<p className="bo-hint">{t('sp.noManagers')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.managers.map((m) => (
|
||||
<li key={m.id}>
|
||||
<span className="bo-grow">
|
||||
<b>{m.name}</b>
|
||||
<span className="bo-small bo-faint" style={{ display: 'block' }}>
|
||||
{t('sp.managerSince', { date: date(m.since) })}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => confirm(t('sp.removeManagerConfirm', { name: m.name })) && removeManager.mutate({ id: m.id })}>
|
||||
{t('sp.remove')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('sp.staff')}</h2>
|
||||
<span className="bo-faint bo-small">{t('sp.staffHint')}</span>
|
||||
</div>
|
||||
{d.staff.length === 0 ? (
|
||||
<p className="bo-hint">{t('sp.noStaff')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.staff.map((s) => (
|
||||
<li key={s.id} style={{ opacity: s.active ? 1 : 0.5 }}>
|
||||
<a href={`#/providers/${s.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<b>{s.name}</b>
|
||||
<span className="bo-small bo-faint" style={{ display: 'block' }}>
|
||||
{[s.phone, s.maxOrdersPerDay ? t('sp.limitOrders', { n: s.maxOrdersPerDay }) : null, s.maxHoursPerDay ? t('sp.limitHours', { n: s.maxHoursPerDay }) : null].filter(Boolean).join(' · ') || '—'}
|
||||
</span>
|
||||
</a>
|
||||
{!s.linked && <span className="bo-chip bo-chip--warn">{t('sp.notLinked')}</span>}
|
||||
{!s.active && <span className="bo-chip">{t('sp.off')}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('sp.orders')}</h2>
|
||||
</div>
|
||||
{d.orders.length === 0 ? (
|
||||
<p className="bo-hint">{t('sp.noOrders')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.orders.map((o) => (
|
||||
<li key={o.id}>
|
||||
<a href={`#/orders/${o.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<b>
|
||||
#{o.number} · {shared(`services.${o.service}.name`)}
|
||||
</b>
|
||||
<span className="bo-small bo-faint" style={{ display: 'block' }}>
|
||||
{dateTime(o.scheduledAt)} · {o.staff ?? t('sp.waitingStaff')}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="bo-col">
|
||||
<section className="bo-panel bo-money-card">
|
||||
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
|
||||
{t('sp.toPay')}
|
||||
</span>
|
||||
<div className="bo-bignum" style={{ margin: '6px 0 8px' }}>
|
||||
{amd(d.money.balance)}
|
||||
</div>
|
||||
<div className="bo-small" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<span>
|
||||
{t('ag.accrued')} <b className="bo-num">{amd(d.money.accrued)}</b>
|
||||
</span>
|
||||
<span>
|
||||
{t('prv.paidTotal')} <b className="bo-num">{amd(d.money.paid)}</b>
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="bo-btn bo-btn--sm" disabled={d.money.balance <= 0} onClick={() => setDlg('payout')}>
|
||||
{t('prv.payout')}
|
||||
</button>
|
||||
{d.money.months.length > 0 && (
|
||||
<ul className="bo-list" style={{ marginTop: 12 }}>
|
||||
{d.money.months.map((m) => (
|
||||
<li key={m.period}>
|
||||
<span className="bo-grow" style={{ textTransform: 'capitalize' }}>
|
||||
{formatDateTime(locale as Locale, new Date(`${m.period}-15T12:00:00+04:00`), { month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="bo-small" style={{ opacity: 0.75 }}>
|
||||
{t('ag.orders', { n: m.orders })} · {amd(m.revenue)}
|
||||
</span>
|
||||
<b className="bo-num">{amd(m.amount)}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('sp.byOrder')}</h2>
|
||||
</div>
|
||||
{d.money.recent.length === 0 ? (
|
||||
<p className="bo-hint">{t('sp.noIncome')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.money.recent.slice(0, 20).map((e) => (
|
||||
<li key={e.orderId}>
|
||||
<a href={`#/orders/${e.orderId}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<b>#{e.number}</b> <span className="bo-small bo-faint">{date(e.at)}</span>
|
||||
<span className="bo-small bo-faint" style={{ display: 'block' }}>
|
||||
{t('sp.paid', { sum: amd(e.paid) })}
|
||||
{e.deal === 'PROFIT_SHARE' ? ` · ${t('sp.fotTax', { fot: amd(e.fot), tax: amd(e.tax) })}` : ` · −${e.pct}%`}
|
||||
</span>
|
||||
</a>
|
||||
<b className="bo-num">{amd(e.amount)}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
{d.note && (
|
||||
<section className="bo-panel">
|
||||
<p className="bo-side-panel__note" style={{ margin: 0 }}>
|
||||
{d.note}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{dlg === 'edit' && <PartnerDialog partner={d} onClose={() => setDlg(null)} onDone={refresh} />}
|
||||
{dlg === 'payout' && <PartnerPayout id={d.id} balance={d.money.balance} onClose={() => 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 (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={partner ? t('sp.editTitle') : t('sp.add')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn"
|
||||
disabled={!f.name.trim() || save.isPending}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
id: partner?.id,
|
||||
name: f.name,
|
||||
contactPhone: f.contactPhone || null,
|
||||
note: f.note || null,
|
||||
active: f.active,
|
||||
deal: f.deal,
|
||||
commissionPct: num(f.commissionPct),
|
||||
profitSharePct: num(f.profitSharePct),
|
||||
fotPct: opt(f.fotPct),
|
||||
taxPct: opt(f.taxPct),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('crm.name')}>
|
||||
<input className="bo-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('sp.phone')}>
|
||||
<input className="bo-input" value={f.contactPhone} maxLength={30} onChange={(e) => setF({ ...f, contactPhone: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('sp.service')}>
|
||||
<input className="bo-input" value={t('sp.cleaningOnly')} disabled />
|
||||
</Field>
|
||||
<Field label={t('sp.deal')}>
|
||||
<div className="bo-chtabs">
|
||||
{(['COMMISSION', 'PROFIT_SHARE'] as const).map((d) => (
|
||||
<button key={d} type="button" aria-pressed={f.deal === d} onClick={() => setF({ ...f, deal: d })}>
|
||||
{t(`sp.deal.${d}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<p className="bo-hint" style={{ marginTop: -4 }}>
|
||||
{t(`sp.dealHint.${f.deal}`)}
|
||||
</p>
|
||||
{f.deal === 'COMMISSION' ? (
|
||||
<Field label={t('sp.commissionPct')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.commissionPct} onChange={(e) => setF({ ...f, commissionPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t('sp.profitSharePct')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.profitSharePct} onChange={(e) => setF({ ...f, profitSharePct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
|
||||
</Field>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('sp.fotPct')} hint={t('sp.defaultHint', { n: defaults.fotPct })}>
|
||||
<input className="bo-input" inputMode="numeric" placeholder={String(defaults.fotPct)} value={f.fotPct} onChange={(e) => setF({ ...f, fotPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
|
||||
</Field>
|
||||
<Field label={t('sp.taxPct')} hint={t('sp.defaultHint', { n: defaults.taxPct })}>
|
||||
<input className="bo-input" inputMode="numeric" placeholder={String(defaults.taxPct)} value={f.taxPct} onChange={(e) => setF({ ...f, taxPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="bo-side-panel__note" style={{ margin: '0 0 12px' }}>
|
||||
{example}
|
||||
</p>
|
||||
<Field label={t('crm.note')}>
|
||||
<textarea className="bo-textarea" style={{ minHeight: 56 }} value={f.note} maxLength={2000} onChange={(e) => setF({ ...f, note: e.target.value })} />
|
||||
</Field>
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
|
||||
{t('sp.activeLabel')}
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PartnerPayout({ id, balance, onClose, onDone }: { id: string; balance: number; onClose: () => void; onDone: () => void }) {
|
||||
const { t, amd } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const [amount, setAmount] = useState(String(balance));
|
||||
const [note, setNote] = useState('');
|
||||
const save = useMutation(
|
||||
trpc.bo.partners.servicePartnerPayout.mutationOptions({
|
||||
onSuccess: () => {
|
||||
onDone();
|
||||
toast(t('prv.payoutDone'));
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={t('sp.payoutTitle')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!Number(amount) || save.isPending} onClick={() => save.mutate({ id, amount: Number(amount), note: note || undefined })}>
|
||||
{t('prv.payoutConfirm')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label={t('prv.amount')} hint={t('ag.balanceHint', { sum: amd(balance) })}>
|
||||
<input className="bo-input" inputMode="numeric" value={amount} onChange={(e) => setAmount(e.target.value.replace(/\D/g, ''))} />
|
||||
</Field>
|
||||
<Field label={t('prv.payoutNote')}>
|
||||
<input className="bo-input" value={note} maxLength={300} onChange={(e) => setNote(e.target.value)} placeholder={t('prv.payoutNotePh')} />
|
||||
</Field>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
giveConsent,
|
||||
rateOrder,
|
||||
linkWebRequest,
|
||||
clientPoints,
|
||||
pointsHistory,
|
||||
completeWebRequest,
|
||||
webRequestSlots,
|
||||
type LinkResult,
|
||||
setClientChannel,
|
||||
setLocale,
|
||||
startClient,
|
||||
@@ -41,6 +46,8 @@ const bot = new Bot(token || 'disabled:token');
|
||||
|
||||
/** Ожидаем ввод кода агентства от этих чатов */
|
||||
const awaitingCode = new Set<number>();
|
||||
/** Заявка с сайта без адреса: следующий текст — адрес (chatId → заявка) */
|
||||
const awaitingAddress = new Map<number, string>();
|
||||
/** Низкая оценка: следующий текст — комментарий к ней (chatId → заказ и оценка) */
|
||||
const awaitingRatingComment = new Map<number, { orderId: string; score: number }>();
|
||||
|
||||
@@ -75,9 +82,11 @@ async function askConsent(c: Context, locale: Locale) {
|
||||
await c.reply(t('bot.client.consentText', { url: `${LANDING}/${locale}/privacy` }), { reply_markup: kb, parse_mode: 'HTML', link_preview_options: { is_disabled: true } });
|
||||
}
|
||||
|
||||
async function showMain(c: Context, locale: Locale, gift?: { endsAt: Date } | null) {
|
||||
const planName = (plan: string) => (plan === 'ELITE' ? 'Barev Elite' : 'Barev+');
|
||||
|
||||
async function showMain(c: Context, locale: Locale, gift?: { endsAt: Date; plan: string } | null) {
|
||||
const t = createT(locale);
|
||||
if (gift) await c.reply(t('bot.client.giftActivated', { date: formatDateTime(locale, gift.endsAt, { dateStyle: 'long' }) }));
|
||||
if (gift) await c.reply(t('bot.client.giftActivated', { plan: planName(gift.plan), date: formatDateTime(locale, gift.endsAt, { dateStyle: 'long' }) }));
|
||||
await c.reply(t('bot.client.welcome'), { reply_markup: mainKeyboard(locale) });
|
||||
}
|
||||
|
||||
@@ -87,19 +96,107 @@ async function continueOnboarding(c: Context, locale: Locale) {
|
||||
return showMain(c, locale);
|
||||
}
|
||||
|
||||
// ───── заявка с сайта: доспросить район, адрес и время ─────
|
||||
|
||||
async function webRequestDone(c: Context, locale: Locale, link: LinkResult) {
|
||||
const t = createT(locale);
|
||||
if (link.kind === 'ordered') {
|
||||
const date = formatDateTime(locale, link.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
await c.reply(t('bot.client.webRequestOrdered', { request: link.requestNumber, order: link.orderNumber, date }));
|
||||
} else if (link.kind === 'linked' || link.kind === 'incomplete') {
|
||||
await c.reply(t('bot.client.webRequestLinked', { request: link.requestNumber }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Следующий вопрос по заявке: сначала район, потом адрес, потом день и время */
|
||||
async function webRequestStep(c: Context, locale: Locale, clientId: string, link: Extract<LinkResult, { kind: 'incomplete' }>, first = false) {
|
||||
const t = createT(locale);
|
||||
const chatId = c.chat!.id;
|
||||
if (first) await c.reply(t('bot.client.wr.intro', { request: link.requestNumber }));
|
||||
const next = link.missing[0];
|
||||
if (next === 'district') {
|
||||
const kb = new InlineKeyboard();
|
||||
const districts = await db.district.findMany({ orderBy: { sortOrder: 'asc' } });
|
||||
districts.forEach((d, i) => {
|
||||
kb.text(t(`districts.${d.code}`), `wrd:${link.requestId}:${d.code}`);
|
||||
if (i % 2 === 1) kb.row();
|
||||
});
|
||||
await c.reply(t('bot.client.wr.askDistrict'), { reply_markup: kb });
|
||||
return;
|
||||
}
|
||||
if (next === 'address') {
|
||||
awaitingAddress.set(chatId, link.requestId);
|
||||
await c.reply(t('bot.client.wr.askAddress'));
|
||||
return;
|
||||
}
|
||||
const days = await webRequestSlots(ctx, { requestId: link.requestId, clientId });
|
||||
if (!days.length) {
|
||||
await c.reply(t('bot.client.wr.noSlots'));
|
||||
return showMain(c, locale);
|
||||
}
|
||||
const kb = new InlineKeyboard();
|
||||
days.slice(0, 8).forEach((d, i) => {
|
||||
kb.text(formatDateTime(locale, d.starts[0]!, { weekday: 'short', day: 'numeric', month: 'short' }), `wrday:${link.requestId}:${d.dateKey}`);
|
||||
if (i % 2 === 1) kb.row();
|
||||
});
|
||||
await c.reply(t('bot.client.wr.askDay'), { reply_markup: kb });
|
||||
}
|
||||
|
||||
async function webRequestAnswer(c: Context, input: { requestId: string; districtCode?: string; addressText?: string; preferredAt?: Date }) {
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
|
||||
if (!client) return;
|
||||
const locale = client.user.locale as Locale;
|
||||
try {
|
||||
const res = await completeWebRequest(ctx, { ...input, clientId: client.id });
|
||||
if (res.kind === 'incomplete') return webRequestStep(c, locale, client.id, res);
|
||||
await webRequestDone(c, locale, res);
|
||||
return showMain(c, locale);
|
||||
} catch (e) {
|
||||
if (e instanceof DomainError) return void (await c.reply(createT(locale)(`errors.${e.code}`)));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
bot.callbackQuery(/^wrd:(\w+):(\w+)$/, async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
await c.editMessageReplyMarkup().catch(() => undefined);
|
||||
await webRequestAnswer(c, { requestId: c.match[1]!, districtCode: c.match[2]! });
|
||||
});
|
||||
|
||||
bot.callbackQuery(/^wrday:(\w+):(\d{4}-\d{2}-\d{2})$/, async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from.id));
|
||||
if (!client) return;
|
||||
const locale = client.user.locale as Locale;
|
||||
const t = createT(locale);
|
||||
const [day] = await webRequestSlots(ctx, { requestId: c.match[1]!, clientId: client.id, dateKey: c.match[2]! }).catch(() => []);
|
||||
if (!day) return void (await c.reply(t('bot.client.wr.dayFull')));
|
||||
const kb = new InlineKeyboard();
|
||||
day.starts.slice(0, 24).forEach((s, i) => {
|
||||
kb.text(formatDateTime(locale, s, { hour: '2-digit', minute: '2-digit' }), `wrt:${c.match[1]}:${Math.round(s.getTime() / 60_000).toString(36)}`);
|
||||
if (i % 4 === 3) kb.row();
|
||||
});
|
||||
await c.editMessageText(t('bot.client.wr.askTime', { day: formatDateTime(locale, day.starts[0]!, { weekday: 'long', day: 'numeric', month: 'long' }) }), { reply_markup: kb }).catch(() => c.reply(t('bot.client.wr.askTime'), { reply_markup: kb }));
|
||||
});
|
||||
|
||||
bot.callbackQuery(/^wrt:(\w+):([0-9a-z]+)$/, async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
await c.editMessageReplyMarkup().catch(() => undefined);
|
||||
await webRequestAnswer(c, { requestId: c.match[1]!, preferredAt: new Date(parseInt(c.match[2]!, 36) * 60_000) });
|
||||
});
|
||||
|
||||
bot.command('start', async (c) => {
|
||||
const res = await startClient(ctx, c.from!, c.match || undefined);
|
||||
// пришёл с сайта после заявки: язык и согласие уже есть — сразу говорим, что с заявкой
|
||||
const token = decodeStartPayload(c.match).token;
|
||||
const link = token ? await linkWebRequest(ctx, token, res.clientId) : null;
|
||||
if (link && link.kind !== 'none') {
|
||||
const t = createT(res.locale);
|
||||
if (link.kind === 'ordered') {
|
||||
const date = formatDateTime(res.locale, link.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
await c.reply(t('bot.client.webRequestOrdered', { request: link.requestNumber, order: link.orderNumber, date }));
|
||||
} else {
|
||||
await c.reply(t('bot.client.webRequestLinked', { request: link.requestNumber }));
|
||||
if (link.kind === 'incomplete') {
|
||||
const gift = link.gift ?? res.gift;
|
||||
if (gift) await c.reply(createT(res.locale)('bot.client.giftActivated', { plan: planName(gift.plan), date: formatDateTime(res.locale, gift.endsAt, { dateStyle: 'long' }) }));
|
||||
return webRequestStep(c, res.locale, res.clientId, link, true);
|
||||
}
|
||||
await webRequestDone(c, res.locale, link);
|
||||
return showMain(c, res.locale, link.gift ?? res.gift);
|
||||
}
|
||||
if (res.needsLocale) {
|
||||
@@ -158,6 +255,41 @@ bot.command('app', async (c) => {
|
||||
|
||||
bot.command('help', async (c) => c.reply(createT(await localeOf(c.from))('bot.client.help')));
|
||||
|
||||
// ───── барики ─────
|
||||
|
||||
const fmtPts = (locale: Locale, n: number) => new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale).format(n);
|
||||
const pointsKb = (locale: Locale) => (APP_URL.startsWith('https://') ? new InlineKeyboard().webApp(createT(locale)('bot.client.points.open'), `${APP_URL}?r=/points`) : undefined);
|
||||
|
||||
bot.command('balance', async (c) => {
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
|
||||
const locale = await localeOf(c.from);
|
||||
if (!client) return;
|
||||
const t = createT(locale);
|
||||
const p = await clientPoints(ctx, client.id);
|
||||
const lines = [t('bot.client.points.balance', { balance: fmtPts(locale, p.balance) })];
|
||||
if (p.pending) lines.push(t('bot.client.points.pending', { pending: fmtPts(locale, p.pending), date: p.pendingNextAt ? formatDateTime(locale, p.pendingNextAt, { day: 'numeric', month: 'long' }) : '—' }));
|
||||
if (p.burnAt) lines.push(t('bot.client.points.burn', { date: formatDateTime(locale, p.burnAt, { day: 'numeric', month: 'long' }) }));
|
||||
await c.reply(lines.join('\n'), { reply_markup: pointsKb(locale) });
|
||||
});
|
||||
|
||||
bot.command('history', async (c) => {
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
|
||||
const locale = await localeOf(c.from);
|
||||
if (!client) return;
|
||||
const t = createT(locale);
|
||||
const rows = await pointsHistory(db, client.id, { take: 10 });
|
||||
if (!rows.length) return void (await c.reply(t('bot.client.points.empty')));
|
||||
const name = (m: Record<string, string> | null) => (m ? m[locale] || m.ru || '' : '');
|
||||
const text = rows
|
||||
.map((r) => {
|
||||
const sign = r.amount > 0 ? '+' : '−';
|
||||
const what = r.type === 'bonus' ? name(r.bonus) : r.type === 'accrual' ? name(r.category) : t(`bot.client.points.type.${r.type}`);
|
||||
return `${formatDateTime(locale, r.at, { day: '2-digit', month: '2-digit' })} ${sign}${fmtPts(locale, Math.abs(r.amount))} · ${what}${r.orderRef ? ` ${r.orderRef}` : ''} · ${t(`bot.client.points.status.${r.status}`)}`;
|
||||
})
|
||||
.join('\n');
|
||||
await c.reply(text, { reply_markup: pointsKb(locale) });
|
||||
});
|
||||
|
||||
/** Сообщение в чат команды: подписчик пишет консьержу, остальные — поддержке. Ответ придёт сюда же */
|
||||
async function toTeam(c: Context, text: string, file?: ChatFile) {
|
||||
const locale = await localeOf(c.from);
|
||||
@@ -228,6 +360,12 @@ bot.on('message:text', async (c) => {
|
||||
await rateOrder(ctx, { orderId: pending.orderId, clientId: client.id, score: pending.score, comment: c.message.text.slice(0, 1000) }).catch(() => undefined);
|
||||
return toTeam(c, c.message.text);
|
||||
}
|
||||
// адрес для заявки с сайта
|
||||
const requestId = awaitingAddress.get(c.chat.id);
|
||||
if (requestId && client) {
|
||||
awaitingAddress.delete(c.chat.id);
|
||||
return webRequestAnswer(c, { requestId, addressText: c.message.text });
|
||||
}
|
||||
if (!awaitingCode.has(c.chat.id)) return toTeam(c, c.message.text);
|
||||
if (!client) return;
|
||||
try {
|
||||
@@ -256,6 +394,8 @@ async function main() {
|
||||
{ command: 'start', description: t('bot.client.cmdStart') },
|
||||
{ command: 'app', description: t('bot.client.cmdApp') },
|
||||
{ command: 'code', description: t('bot.client.cmdCode') },
|
||||
{ command: 'balance', description: t('bot.client.cmdBalance') },
|
||||
{ command: 'history', description: t('bot.client.cmdHistory') },
|
||||
{ command: 'support', description: t('bot.client.cmdSupport') },
|
||||
{ command: 'concierge', description: t('bot.client.cmdConcierge') },
|
||||
{ command: 'language', description: t('bot.client.cmdLanguage') },
|
||||
|
||||
+105
-3
@@ -9,6 +9,12 @@ import {
|
||||
acceptPartnerInvite,
|
||||
addProviderMessage,
|
||||
checkCard,
|
||||
accruePoints,
|
||||
confirmRedemption,
|
||||
getSettings,
|
||||
previewAccrual,
|
||||
rejectRedemption,
|
||||
createProWebLogin,
|
||||
DomainError,
|
||||
linkByInvite,
|
||||
peerName,
|
||||
@@ -42,12 +48,25 @@ async function identity(c: Context) {
|
||||
}
|
||||
const localeOf = (id: Awaited<ReturnType<typeof identity>>): Locale => (id?.user.locale as Locale) ?? 'ru';
|
||||
|
||||
function cabinetKeyboard(locale: Locale) {
|
||||
function cabinetKeyboard(locale: Locale, manager = false) {
|
||||
const kb = new InlineKeyboard();
|
||||
if (APP_URL.startsWith('https://')) kb.webApp(createT(locale)('bot.pro.openCabinet'), APP_URL);
|
||||
if (APP_URL.startsWith('https://')) kb.webApp(createT(locale)('bot.pro.openCabinet'), APP_URL).row();
|
||||
// менеджер партнёра может открыть кабинет и на компьютере
|
||||
if (manager) kb.text(createT(locale)('bot.pro.openWeb'), 'web');
|
||||
return kb;
|
||||
}
|
||||
|
||||
/** Одноразовая ссылка на кабинет в браузере (15 минут) */
|
||||
async function sendWebLink(c: Context) {
|
||||
const id = await identity(c);
|
||||
const t = createT(localeOf(id));
|
||||
if (!id || id.companies.length === 0) return void (await c.reply(t('bot.pro.notLinked')));
|
||||
const code = await createProWebLogin(ctx, id.user.id);
|
||||
const url = `${APP_URL || 'http://localhost:5175'}/?login=${code}#/company`;
|
||||
const kb = url.startsWith('https://') ? new InlineKeyboard().url(t('bot.pro.openWeb'), url) : undefined;
|
||||
await c.reply(t('bot.pro.webLink', { url }), { reply_markup: kb, link_preview_options: { is_disabled: true } });
|
||||
}
|
||||
|
||||
async function greet(c: Context) {
|
||||
const id = await identity(c);
|
||||
const locale = localeOf(id);
|
||||
@@ -61,7 +80,7 @@ async function greet(c: Context) {
|
||||
...id.companies.map((co) => t('bot.pro.youAreDispatcher', { company: co.name })),
|
||||
...id.partners.map((p) => t(p.role === 'OWNER' ? 'bot.partner.youAreOwner' : 'bot.partner.youAreStaff', { partner: p.name })),
|
||||
].filter(Boolean);
|
||||
await c.reply(`${roles.join('\n')}\n\n${t('bot.pro.welcome')}`, { reply_markup: cabinetKeyboard(locale) });
|
||||
await c.reply(`${roles.join('\n')}\n\n${t('bot.pro.welcome')}`, { reply_markup: cabinetKeyboard(locale, id.companies.length > 0) });
|
||||
}
|
||||
|
||||
/** Оператор админки, привязавший этот Telegram */
|
||||
@@ -173,6 +192,11 @@ bot.command('start', async (c) => {
|
||||
});
|
||||
|
||||
bot.command('app', greet);
|
||||
bot.command('web', sendWebLink);
|
||||
bot.callbackQuery('web', async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
await sendWebLink(c);
|
||||
});
|
||||
|
||||
/** PRO-5: принять / отклонить из уведомления */
|
||||
bot.callbackQuery(/^offer:(accept|decline):(\w+)$/, async (c) => {
|
||||
@@ -214,6 +238,84 @@ bot.callbackQuery(/^rd:(\w+):(\w+)$/, async (c) => {
|
||||
});
|
||||
|
||||
/** «Ответить» под уведомлением о сообщении */
|
||||
// ───── барики: быстрые действия команды ─────
|
||||
|
||||
/** Кто может управлять бариками из бота: суперадмин или поддержка, если разрешено в настройках */
|
||||
async function pointsActor(c: Context) {
|
||||
const admin = await adminOf(c);
|
||||
if (!admin) return null;
|
||||
const owner = admin.roles.includes('SUPERADMIN');
|
||||
if (!owner && !(admin.roles.includes('SUPPORT') && (await getSettings(db))['points.supportCanManage'])) return null;
|
||||
return { adminId: admin.id, owner };
|
||||
}
|
||||
const fmt = (n: number) => new Intl.NumberFormat('ru-RU').format(n);
|
||||
async function pointsError(c: Context, e: unknown) {
|
||||
if (!(e instanceof DomainError)) throw e;
|
||||
await c.reply(adminT(`errors.${e.code}`));
|
||||
}
|
||||
|
||||
bot.callbackQuery(/^pr:(ok|no):(\w+)$/, async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
const actor = await pointsActor(c);
|
||||
if (!actor) return void (await c.reply(adminT('bot.admin.notAdmin')));
|
||||
const r = await db.pointsRedemption.findUnique({ where: { id: c.match[2]! }, include: { order: true } });
|
||||
if (!r) return;
|
||||
try {
|
||||
if (c.match[1] === 'ok') {
|
||||
if (!r.order) return;
|
||||
const res = await confirmRedemption(ctx, { redemptionId: r.id, orderAmount: r.order.totalPrice }, actor);
|
||||
await c.editMessageReplyMarkup().catch(() => undefined);
|
||||
await c.reply(adminT('bot.admin.points.confirmed', { amount: fmt(res.amount) }));
|
||||
} else {
|
||||
await rejectRedemption(ctx, { redemptionId: r.id, reason: 'declined' }, actor);
|
||||
await c.editMessageReplyMarkup().catch(() => undefined);
|
||||
await c.reply(adminT('bot.admin.points.rejected'));
|
||||
}
|
||||
} catch (e) {
|
||||
await pointsError(c, e);
|
||||
}
|
||||
});
|
||||
|
||||
/** /barik <телефон или @username> <сумма заказа> [категория] — подсказка и кнопка «Начислить» */
|
||||
bot.command('barik', async (c) => {
|
||||
const actor = await pointsActor(c);
|
||||
if (!actor) return void (await c.reply(adminT('bot.admin.notAdmin')));
|
||||
const parts = (c.match ?? '').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 2) return void (await c.reply(adminT('bot.admin.points.help')));
|
||||
const [who, sumRaw, catRaw] = parts;
|
||||
const sum = Number(sumRaw!.replace(/[^\d]/g, ''));
|
||||
const digits = who!.replace(/[^\d]/g, '');
|
||||
const user = who!.startsWith('@')
|
||||
? await db.user.findFirst({ where: { username: { equals: who!.slice(1), mode: 'insensitive' } }, include: { client: true } })
|
||||
: digits.length >= 8
|
||||
? await db.user.findFirst({ where: { phone: { endsWith: digits.slice(-8) } }, include: { client: true } })
|
||||
: null;
|
||||
if (!user?.client) return void (await c.reply(adminT('bot.admin.points.noClient')));
|
||||
const cat = await db.pointsCategory.findUnique({ where: { code: (catRaw ?? 'cleaning').toLowerCase() } });
|
||||
if (!cat || !sum) return void (await c.reply(adminT('bot.admin.points.help')));
|
||||
const p = await previewAccrual(db, { clientId: user.client.id, categoryId: cat.id, orderAmount: sum, serviceDate: new Date() }, await getSettings(db), new Date());
|
||||
const name = [user.firstName, user.lastName].filter(Boolean).join(' ') || `@${user.username}`;
|
||||
const catName = (cat.name as Record<string, string>).ru ?? cat.code;
|
||||
const tier = p.tier === 'ELITE' ? 'Elite' : p.tier === 'BAREV_PLUS' ? 'Barev+' : 'Barev';
|
||||
const kb = new InlineKeyboard().text(adminT('bot.admin.points.accrue'), `pa:${user.client.id}:${cat.code}:${sum}`);
|
||||
await c.reply(adminT('bot.admin.points.preview', { amount: fmt(p.suggested), name, what: catName, sum: fmt(sum), tier, rate: p.ratePct, date: formatDateTime('ru', p.availableAt, { day: 'numeric', month: 'long' }) }), { reply_markup: kb });
|
||||
});
|
||||
|
||||
bot.callbackQuery(/^pa:(\w+):([a-z_]+):(\d+)$/, async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
const actor = await pointsActor(c);
|
||||
if (!actor) return;
|
||||
const cat = await db.pointsCategory.findUnique({ where: { code: c.match[2]! } });
|
||||
if (!cat) return;
|
||||
try {
|
||||
const r = await accruePoints(ctx, { clientId: c.match[1]!, kind: 'ORDER', categoryId: cat.id, orderAmount: Number(c.match[3]), serviceDate: new Date(), channel: 'BOT', idempotencyKey: `bot:${c.callbackQuery.message?.message_id}:${c.match[1]}` }, actor);
|
||||
await c.editMessageReplyMarkup().catch(() => undefined);
|
||||
await c.reply(r.status === 'created' ? adminT('bot.admin.points.done', { amount: fmt(r.amount) }) : r.status === 'needs_approval' ? adminT('bot.admin.points.needsApproval') : adminT('bot.admin.points.zero'));
|
||||
} catch (e) {
|
||||
await pointsError(c, e);
|
||||
}
|
||||
});
|
||||
|
||||
bot.callbackQuery(/^ar:(c|p|t):(\w+)$/, async (c) => {
|
||||
const admin = await adminOf(c);
|
||||
if (!admin) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { CleanForm } from '../../../components/clean';
|
||||
import { FooterContacts, LegalLine } from '../../../components/seo';
|
||||
import { LegalLinks } from '../../../components/legal';
|
||||
import { BotLink, BotLinkProvider, LangMenu, LangSwitch } from '../../../components/client';
|
||||
import { getCleaning } from '../../../lib/data';
|
||||
|
||||
@@ -113,7 +114,7 @@ export default async function CleanPage({ params }: { params: Promise<{ locale:
|
||||
<div className="footer__links">
|
||||
<a href={`/${locale}`}>{t('landing.clean.home')}</a>
|
||||
<FooterContacts />
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
<LegalLinks locale={locale} />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Icon } from '@barev/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LegalShell, legalMetadata } from '../../../components/legal';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return legalMetadata(params, 'pricing');
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
price: string;
|
||||
vars?: Record<string, string>;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Цены: подписки клуба и услуги, которые сейчас продаются. Все суммы — из настроек админки
|
||||
* (как на главной и страницах услуг), поэтому страница не расходится с ботом.
|
||||
*/
|
||||
export default async function Pricing({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const amd = (n: number) => formatAmd(locale, n);
|
||||
const { offer, barevPlus, categories } = await getCatalog();
|
||||
const cleaning = categories.find((c) => c.code === 'HOME')?.services.find((s) => s.code === 'cleaning')?.fromPrice ?? 0;
|
||||
const from = (n: number) => t('pricing.from', { price: amd(n) });
|
||||
|
||||
const plans = [
|
||||
{ id: 'free', name: 'Barev', price: t('landing.club.free'), pct: 0 },
|
||||
{ id: 'plus', name: 'Barev+', price: t('landing.club.perMonth', { price: amd(barevPlus.monthlyPrice) }), pct: offer.plusMaxPct },
|
||||
{ id: 'elite', name: 'Barev Elite', price: t('landing.club.perMonth', { price: amd(offer.elitePrice) }), pct: offer.eliteMaxPct },
|
||||
] as const;
|
||||
|
||||
const groups: { id: 'HOME' | 'DOCS'; rows: Row[] }[] = [
|
||||
{
|
||||
id: 'HOME',
|
||||
rows: [
|
||||
{ id: 'cleaning', price: from(cleaning), href: '/clean' },
|
||||
{ id: 'transferStandard', price: amd(offer.transferStandard), href: '/airport' },
|
||||
{ id: 'transferPremium', price: amd(offer.transferPremium), href: '/airport' },
|
||||
{ id: 'hotel', price: t('pricing.perDay', { price: from(offer.hotelWithPlus) }), vars: { base: amd(offer.hotelBase) } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'DOCS',
|
||||
rows: [
|
||||
{ id: 'socialCard', price: amd(offer.socialCard), href: '/social-card' },
|
||||
{ id: 'consultation', price: amd(offer.consultation), href: '/residence-permit' },
|
||||
{ id: 'residence', price: from(offer.residenceEscortFrom), vars: { fee: amd(offer.residenceStateFee) }, href: '/residence-permit' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<LegalShell locale={locale} page="pricing">
|
||||
<section className="policy__section" id="plans">
|
||||
<h2>{t('pricing.plansTitle')}</h2>
|
||||
<p>{t('pricing.plansNote')}</p>
|
||||
<div className="pr-plans">
|
||||
{plans.map((p) => (
|
||||
<article key={p.id} className="pr-plan" data-plan={p.id}>
|
||||
<h3>{p.name}</h3>
|
||||
<span className="pr-plan__price">{p.price}</span>
|
||||
<ul>
|
||||
{[1, 2, 3].map((n) => (
|
||||
<li key={n}>
|
||||
<Icon name="check" size={16} strokeWidth={2.6} />
|
||||
{t(`landing.club.${p.id}F${n}`, { pct: p.pct })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<small>{t(`pricing.planNote.${p.id}`, { days: barevPlus.giftDays })}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="policy__section" id="services">
|
||||
<h2>{t('pricing.servicesTitle')}</h2>
|
||||
<p>{t('pricing.servicesNote')}</p>
|
||||
{groups.map((g) => (
|
||||
<div key={g.id} className="pr-group">
|
||||
<h3>{t(`categories.${g.id}.name`)}</h3>
|
||||
<dl className="pr-list">
|
||||
{g.rows.map((r) => (
|
||||
<div key={r.id} className="pr-row">
|
||||
<dt>
|
||||
{r.href ? <a href={`/${locale}${r.href}`}>{t(`pricing.rows.${r.id}.name`)}</a> : t(`pricing.rows.${r.id}.name`)}
|
||||
<small>{t(`pricing.rows.${r.id}.note`, r.vars)}</small>
|
||||
</dt>
|
||||
<dd>{r.price}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="policy__section" id="payment">
|
||||
<h2>{t('pricing.payTitle')}</h2>
|
||||
<p>{t('pricing.payBody')}</p>
|
||||
<p>
|
||||
<a href={`/${locale}/terms`}>{t('landing.footer.terms')}</a> · <a href={`/${locale}/refund`}>{t('landing.footer.refund')}</a>
|
||||
</p>
|
||||
</section>
|
||||
</LegalShell>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +1,22 @@
|
||||
import { createT, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Logo } from '@barev/ui';
|
||||
import type { Metadata } from 'next';
|
||||
import { isLocale, LOCALES } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { RichText } from '../../../components/rich-text';
|
||||
import { FooterContacts, LegalLine } from '../../../components/seo';
|
||||
import { CONTACTS, languageAlternates } from '../../../lib/seo';
|
||||
import { LegalDocument, legalMetadata } from '../../../components/legal';
|
||||
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** Разделы политики по порядку; тексты — privacy.<id>.title / privacy.<id>.body */
|
||||
/** Разделы по порядку; тексты — privacy.<id>.title / privacy.<id>.body */
|
||||
const SECTIONS = ['operator', 'data', 'purposes', 'basis', 'recipients', 'location', 'retention', 'security', 'rights', 'marketing', 'cookies', 'age', 'changes'] as const;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = createT(locale);
|
||||
return {
|
||||
title: `${t('privacy.title')} — Barev Club`,
|
||||
description: t('privacy.intro'),
|
||||
alternates: { canonical: `/${locale}/privacy`, languages: languageAlternates('/privacy') },
|
||||
// своё превью ссылки, иначе подставляется старый слоган из общего layout
|
||||
openGraph: { title: `${t('privacy.title')} — Barev Club`, description: t('privacy.intro'), locale, type: 'article', siteName: 'Barev Club' },
|
||||
twitter: { card: 'summary_large_image', title: `${t('privacy.title')} — Barev Club`, description: t('privacy.intro') },
|
||||
};
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return legalMetadata(params, 'privacy');
|
||||
}
|
||||
|
||||
/** ONB-3: политика конфиденциальности. Оператор — ИП владельца; основной текст на русском, переводы needs_review */
|
||||
export default async function Privacy({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const vars = { email: CONTACTS.email, phone: CONTACTS.phoneDisplay, tin: CONTACTS.tin };
|
||||
return (
|
||||
<main className="prose policy">
|
||||
<div className="policy__top">
|
||||
<a href={`/${locale}`} style={{ textDecoration: 'none' }}>
|
||||
<Logo />
|
||||
</a>
|
||||
<nav className="policy__langs" aria-label="Language">
|
||||
{LOCALES.map((l) => (
|
||||
<a key={l} href={`/${l}/privacy`} hrefLang={l} aria-current={l === locale ? 'page' : undefined}>
|
||||
{l.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<h1 className="bc-h2" style={{ marginTop: 24 }}>
|
||||
{t('privacy.title')}
|
||||
</h1>
|
||||
<p className="policy__updated">{t('privacy.updated')}</p>
|
||||
<p>{t('privacy.intro')}</p>
|
||||
<ol className="policy__toc">
|
||||
{SECTIONS.map((s) => (
|
||||
<li key={s}>
|
||||
<a href={`#${s}`}>{t(`privacy.${s}.title`)}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{SECTIONS.map((s, i) => (
|
||||
<section key={s} id={s} className="policy__section">
|
||||
<h2>
|
||||
{i + 1}. {t(`privacy.${s}.title`)}
|
||||
</h2>
|
||||
<RichText text={t(`privacy.${s}.body`, vars)} />
|
||||
</section>
|
||||
))}
|
||||
<footer className="policy__footer">
|
||||
<LegalLine locale={locale} />
|
||||
<span className="footer__links">
|
||||
<FooterContacts />
|
||||
</span>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
/** ONB-3: политика конфиденциальности. Оператор — ИП владельца. Основной текст на русском, переводы needs_review */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <LegalDocument locale={locale} ns="privacy" sections={SECTIONS} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isLocale, LOCALES } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LegalDocument, legalMetadata } from '../../../components/legal';
|
||||
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** Разделы по порядку; тексты — refund.<id>.title / refund.<id>.body */
|
||||
const SECTIONS = ['cancelClient', 'cancelUs', 'quality', 'subscription', 'nonRefundable', 'howTo', 'timing'] as const;
|
||||
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return legalMetadata(params, 'refund');
|
||||
}
|
||||
|
||||
/** Правила отмены и возврата денег. Основной текст на русском, переводы needs_review */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <LegalDocument locale={locale} ns="refund" sections={SECTIONS} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isLocale, LOCALES } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LegalDocument, legalMetadata } from '../../../components/legal';
|
||||
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** Разделы по порядку; тексты — terms.<id>.title / terms.<id>.body */
|
||||
const SECTIONS = ['about', 'acceptance', 'orders', 'prices', 'payment', 'cancel', 'subscription', 'perks', 'docs', 'client', 'quality', 'liability', 'content', 'changes', 'law'] as const;
|
||||
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return legalMetadata(params, 'terms');
|
||||
}
|
||||
|
||||
/** Условия использования сервиса (публичная оферта). Основной текст на русском, переводы needs_review */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <LegalDocument locale={locale} ns="terms" sections={SECTIONS} />;
|
||||
}
|
||||
@@ -455,6 +455,33 @@ button.cl-extra {
|
||||
color: #e9c9d5;
|
||||
}
|
||||
|
||||
/* телефон сразу под комнатами и санузлами — единственное обязательное поле, кроме параметров */
|
||||
.cl-hero-phone {
|
||||
max-width: 420px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cl-hero-phone .bc-input {
|
||||
background: #fff;
|
||||
}
|
||||
.cl-hero-phone[data-error='true'] .bc-input {
|
||||
border-color: var(--bc-danger);
|
||||
}
|
||||
/* согласие на обработку данных — нажатием кнопки */
|
||||
.cl-consent-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
.cl-consent-note a {
|
||||
color: inherit;
|
||||
}
|
||||
.cl-consent-note--mobile {
|
||||
display: none;
|
||||
color: var(--bc-ink-2, #6b5d57);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* нижняя панель — только на телефоне */
|
||||
.cl-bar {
|
||||
display: none;
|
||||
@@ -659,6 +686,12 @@ button.cl-extra {
|
||||
.cl-receipt .cl-btn-pink {
|
||||
display: none;
|
||||
}
|
||||
.cl-receipt .cl-consent-note {
|
||||
display: none;
|
||||
}
|
||||
.cl-consent-note--mobile {
|
||||
display: block;
|
||||
}
|
||||
.cl-bar {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
|
||||
@@ -419,6 +419,129 @@ a:hover {
|
||||
.policy__footer a {
|
||||
color: inherit;
|
||||
}
|
||||
.policy__legal {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* страница цен */
|
||||
.pr-plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.pr-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
border: 1.5px solid rgba(74, 16, 40, 0.12);
|
||||
}
|
||||
.pr-plan[data-plan='plus'] {
|
||||
background: var(--bc-pink);
|
||||
border-color: var(--bc-pink);
|
||||
}
|
||||
.pr-plan[data-plan='elite'] {
|
||||
background: var(--bc-perks);
|
||||
border-color: var(--bc-perks);
|
||||
}
|
||||
.pr-plan h3 {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-size: 26px;
|
||||
line-height: 1.1;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.pr-plan__price {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.pr-plan ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.pr-plan li {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.pr-plan li svg {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.pr-plan small {
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.pr-group h3 {
|
||||
margin: 24px 0 4px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-size: 18px;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.pr-list {
|
||||
margin: 0;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
padding: 4px 18px;
|
||||
}
|
||||
.pr-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.pr-row + .pr-row {
|
||||
border-top: 1px solid rgba(74, 16, 40, 0.1);
|
||||
}
|
||||
.pr-row dt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pr-row dt a {
|
||||
color: inherit;
|
||||
}
|
||||
.pr-row dt small {
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.pr-row dd {
|
||||
margin: 0;
|
||||
flex: none;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.pr-plans {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.wrap {
|
||||
padding: 12px 16px 24px;
|
||||
|
||||
@@ -19,10 +19,11 @@ const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
|
||||
const AGENCY_KEY = 'barev.agency';
|
||||
const TYPES = ['regular', 'general', 'renovation'] as const;
|
||||
|
||||
type Errors = Partial<Record<'time' | 'district' | 'street' | 'phone' | 'consent' | 'submit', string>>;
|
||||
type Errors = Partial<Record<'phone' | 'submit', string>>;
|
||||
|
||||
/**
|
||||
* Страница /clean (модель qlean.ru): параметры → доп. услуги → время → адрес → контакты.
|
||||
* Страница /clean (модель qlean.ru): параметры и телефон → доп. услуги → время → адрес → имя.
|
||||
* Обязательны только тип уборки, комнаты, санузлы и телефон; время и адрес бот доспросит, а оператор уточнит.
|
||||
* Цена считается в браузере той же функцией quote, что и на сервере. Заявка сразу видна в админке,
|
||||
* а человек уходит в бота по ссылке, которая привязывает заявку к его Telegram.
|
||||
*/
|
||||
@@ -81,7 +82,6 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
const [note, setNote] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [sending, setSending] = useState(false);
|
||||
const [done, setDone] = useState<{ number: number; url: string } | null>(null);
|
||||
@@ -96,11 +96,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
|
||||
function validate(): Errors {
|
||||
const e: Errors = {};
|
||||
if (!time) e.time = t('landing.clean.err.time');
|
||||
if (!district) e.district = t('landing.clean.err.district');
|
||||
if (street.trim().length < 3) e.street = t('landing.clean.err.street');
|
||||
if (!/^\+?\d{8,15}$/.test(phone.replace(/[\s()\-.]/g, ''))) e.phone = t('landing.clean.err.phone');
|
||||
if (!consent) e.consent = t('landing.clean.err.consent');
|
||||
return e;
|
||||
}
|
||||
|
||||
@@ -127,9 +123,10 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
params,
|
||||
preferredAt: time!.toISOString(),
|
||||
districtCode: district,
|
||||
addressText: street.trim(),
|
||||
preferredAt: time?.toISOString(),
|
||||
districtCode: district || undefined,
|
||||
// короткий адрес не отправляем — бот спросит его заново
|
||||
addressText: street.trim().length >= 3 ? street.trim() : undefined,
|
||||
note: note.trim() || undefined,
|
||||
phone,
|
||||
name: name.trim() || undefined,
|
||||
@@ -211,6 +208,30 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
<Stepper value={baths} min={bathsF.min ?? 1} max={bathsF.max ?? 4} onChange={setBaths} decLabel={t('common.decrease')} incLabel={t('common.increase')} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="bc-field cl-hero-phone" data-error={!!errors.phone}>
|
||||
<span className="bc-label">{t('landing.clean.phone')}</span>
|
||||
<input
|
||||
className="bc-input"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
value={phone}
|
||||
maxLength={30}
|
||||
placeholder="+374 91 123456"
|
||||
aria-invalid={!!errors.phone}
|
||||
aria-describedby="cl-phone-hint"
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
{errors.phone ? (
|
||||
<span className="bc-error" role="alert">
|
||||
{errors.phone}
|
||||
</span>
|
||||
) : (
|
||||
<span id="cl-phone-hint" className="cl-muted cl-small">
|
||||
{t('landing.clean.phoneHint')}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="cl-hero__price" aria-live="polite">
|
||||
<PriceTicker value={q ? amd(q.totalPrice) : '…'} />
|
||||
@@ -254,7 +275,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
</Block>
|
||||
)}
|
||||
|
||||
<Block title={t('landing.clean.whenTitle')} hint={t('landing.clean.whenHint')} error={errors.time}>
|
||||
<Block title={t('landing.clean.whenTitle')} hint={t('landing.clean.whenHintOptional')}>
|
||||
<div className="cl-days" role="listbox" aria-label={t('landing.clean.whenTitle')}>
|
||||
{days.map((d) => (
|
||||
<button
|
||||
@@ -284,11 +305,11 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
)}
|
||||
</Block>
|
||||
|
||||
<Block title={t('landing.clean.addressTitle')} error={errors.district ?? errors.street}>
|
||||
<Block title={t('landing.clean.addressTitle')} hint={t('landing.clean.addressHintOptional')}>
|
||||
<div className="cl-grid2">
|
||||
<label className="bc-field">
|
||||
<span className="bc-label">{t('landing.clean.district')}</span>
|
||||
<select className="bc-input" value={district} onChange={(e) => setDistrict(e.target.value)} aria-invalid={!!errors.district}>
|
||||
<select className="bc-input" value={district} onChange={(e) => setDistrict(e.target.value)}>
|
||||
<option value="">{t('landing.clean.districtPlaceholder')}</option>
|
||||
{config.districts.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
@@ -305,7 +326,6 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
maxLength={300}
|
||||
placeholder={t('landing.clean.streetPlaceholder')}
|
||||
autoComplete="street-address"
|
||||
aria-invalid={!!errors.street}
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
@@ -316,41 +336,14 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
</label>
|
||||
</Block>
|
||||
|
||||
<Block title={t('landing.clean.contactTitle')} error={errors.phone ?? errors.consent}>
|
||||
<div className="cl-grid2">
|
||||
<label className="bc-field">
|
||||
<span className="bc-label">{t('landing.clean.phone')}</span>
|
||||
<input
|
||||
className="bc-input"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
value={phone}
|
||||
maxLength={30}
|
||||
placeholder="+374 91 123456"
|
||||
aria-invalid={!!errors.phone}
|
||||
aria-describedby="cl-phone-hint"
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
<span id="cl-phone-hint" className="cl-muted cl-small">
|
||||
{t('landing.clean.phoneHint')}
|
||||
</span>
|
||||
</label>
|
||||
<label className="bc-field">
|
||||
<span className="bc-label">{t('landing.clean.name')}</span>
|
||||
<input className="bc-input" value={name} maxLength={80} autoComplete="given-name" onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="cl-consent">
|
||||
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} aria-invalid={!!errors.consent} />
|
||||
<span>
|
||||
{t('landing.clean.consent')}.{' '}
|
||||
<a href={`/${locale}/privacy`} target="_blank" rel="noopener">
|
||||
{t('landing.clean.privacy')}
|
||||
</a>
|
||||
</span>
|
||||
<Block title={t('landing.clean.nameTitle')}>
|
||||
<label className="bc-field">
|
||||
<span className="bc-label">{t('landing.clean.name')}</span>
|
||||
<input className="bc-input" value={name} maxLength={80} autoComplete="given-name" onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
</Block>
|
||||
{/* на телефоне кнопка в нижней панели — текст о согласии под формой */}
|
||||
<ConsentNote locale={locale} t={t} className="cl-consent-note cl-consent-note--mobile" />
|
||||
</div>
|
||||
|
||||
<aside className="cl-receipt" aria-label={t('landing.clean.summaryTitle')}>
|
||||
@@ -386,6 +379,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
</div>
|
||||
)}
|
||||
<p className="cl-receipt__after">{t('landing.clean.after')}</p>
|
||||
<ConsentNote locale={locale} t={t} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -403,6 +397,18 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
);
|
||||
}
|
||||
|
||||
/** Согласие на обработку данных — нажатием кнопки, без отдельной галочки */
|
||||
function ConsentNote({ locale, t, className = 'cl-consent-note' }: { locale: Locale; t: (k: string) => string; className?: string }) {
|
||||
return (
|
||||
<p className={className}>
|
||||
{t('landing.clean.consentByClick')}{' '}
|
||||
<a href={`/${locale}/privacy`} target="_blank" rel="noopener">
|
||||
{t('landing.clean.privacy')}
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ title, hint, error, children }: { title: string; hint?: string; error?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="cl-block" data-error={!!error}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Metadata } from 'next';
|
||||
import { getCatalog, type LandingOffer } from '../lib/data';
|
||||
import { BotLink } from './client';
|
||||
import { SERVICE_PAGES } from '../lib/services';
|
||||
import { LegalLinks } from './legal';
|
||||
import { FooterContacts, LegalLine } from './seo';
|
||||
|
||||
/** Главная (/) и «Уже живу» (/live): индексируются, у каждой свой canonical и hreflang */
|
||||
@@ -102,7 +103,7 @@ export function HomeFooter({ locale }: { locale: Locale }) {
|
||||
<span className="h-footer__links">
|
||||
<FooterContacts />
|
||||
<BotLink>{t('landing.footer.partner')}</BotLink>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
<LegalLinks locale={locale} />
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createT, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Logo } from '@barev/ui';
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { CONTACTS, languageAlternates } from '../lib/seo';
|
||||
import { RichText } from './rich-text';
|
||||
import { FooterContacts, LegalLine } from './seo';
|
||||
|
||||
/** Страницы с ценами и правилами: их требуют платёжные провайдеры при проверке сайта */
|
||||
export const LEGAL_PAGES = ['pricing', 'terms', 'refund', 'privacy'] as const;
|
||||
export type LegalPageId = (typeof LEGAL_PAGES)[number];
|
||||
|
||||
/** Ссылки на цены и правила — в подвал любой страницы */
|
||||
export function LegalLinks({ locale }: { locale: Locale }) {
|
||||
const t = createT(locale);
|
||||
return (
|
||||
<>
|
||||
{LEGAL_PAGES.map((p) => (
|
||||
<a key={p} href={`/${locale}/${p}`}>
|
||||
{t(`landing.footer.${p}`)}
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Мета страницы: заголовок и описание из <ns>.title / <ns>.intro */
|
||||
export async function legalMetadata(params: Promise<{ locale: string }>, ns: LegalPageId): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = createT(locale);
|
||||
const title = `${t(`${ns}.title`)} — Barev Club`;
|
||||
const description = t(`${ns}.intro`);
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: `/${locale}/${ns}`, languages: languageAlternates(`/${ns}`) },
|
||||
// своё превью ссылки, иначе подставляется слоган из общего layout
|
||||
openGraph: { title, description, locale, type: 'article', siteName: 'Barev Club' },
|
||||
twitter: { card: 'summary_large_image', title, description },
|
||||
};
|
||||
}
|
||||
|
||||
/** Каркас: логотип, языки, заголовок, подвал с реквизитами и ссылками на остальные правила */
|
||||
export function LegalShell({ locale, page, children }: { locale: Locale; page: LegalPageId; children: ReactNode }) {
|
||||
const t = createT(locale);
|
||||
return (
|
||||
<main className="prose policy">
|
||||
<div className="policy__top">
|
||||
<a href={`/${locale}`} style={{ textDecoration: 'none' }}>
|
||||
<Logo />
|
||||
</a>
|
||||
<nav className="policy__langs" aria-label="Language">
|
||||
{LOCALES.map((l) => (
|
||||
<a key={l} href={`/${l}/${page}`} hrefLang={l} aria-current={l === locale ? 'page' : undefined}>
|
||||
{l.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<h1 className="bc-h2" style={{ marginTop: 24 }}>
|
||||
{t(`${page}.title`)}
|
||||
</h1>
|
||||
<p className="policy__updated">{t(`${page}.updated`)}</p>
|
||||
<p>{t(`${page}.intro`)}</p>
|
||||
{children}
|
||||
<footer className="policy__footer">
|
||||
<LegalLine locale={locale} />
|
||||
<span className="footer__links">
|
||||
<FooterContacts />
|
||||
</span>
|
||||
<nav className="policy__legal" aria-label={t('landing.footer.rules')}>
|
||||
{LEGAL_PAGES.filter((p) => p !== page).map((p) => (
|
||||
<a key={p} href={`/${locale}/${p}`}>
|
||||
{t(`landing.footer.${p}`)}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Текст правил: оглавление и нумерованные разделы <ns>.<id>.title / <ns>.<id>.body */
|
||||
export function LegalDocument({ locale, ns, sections }: { locale: Locale; ns: Exclude<LegalPageId, 'pricing'>; sections: readonly string[] }) {
|
||||
const t = createT(locale);
|
||||
const vars = { email: CONTACTS.email, phone: CONTACTS.phoneDisplay, tin: CONTACTS.tin };
|
||||
return (
|
||||
<LegalShell locale={locale} page={ns}>
|
||||
<ol className="policy__toc">
|
||||
{sections.map((s) => (
|
||||
<li key={s}>
|
||||
<a href={`#${s}`}>{t(`${ns}.${s}.title`)}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{sections.map((s, i) => (
|
||||
<section key={s} id={s} className="policy__section">
|
||||
<h2>
|
||||
{i + 1}. {t(`${ns}.${s}.title`)}
|
||||
</h2>
|
||||
<RichText text={t(`${ns}.${s}.body`, vars)} />
|
||||
</section>
|
||||
))}
|
||||
</LegalShell>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { LOCALES } from '@barev/i18n';
|
||||
export const SITE = (process.env.LANDING_URL ?? 'http://localhost:3000').replace(/\/$/, '');
|
||||
|
||||
/** Страницы, которые должны быть в поиске; экспериментальные /landN закрыты noindex и сюда не входят */
|
||||
export const INDEXED_PATHS = ['', '/live', '/clean', '/social-card', '/residence-permit', '/airport', '/privacy'] as const;
|
||||
export const INDEXED_PATHS = ['', '/live', '/clean', '/social-card', '/residence-permit', '/airport', '/pricing', '/terms', '/refund', '/privacy'] as const;
|
||||
|
||||
/** hreflang для пути: все языки + x-default на русскую версию (абсолютные адреса для sitemap) */
|
||||
export function languageAlternates(path: string, absolute = false): Record<string, string> {
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1 @@
|
||||
{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/libLine truncated
|
||||
@@ -16,6 +16,7 @@ import { Perks } from './screens/Perks.tsx';
|
||||
import { Profile } from './screens/Profile.tsx';
|
||||
import { Services } from './screens/Services.tsx';
|
||||
import { Chat } from './screens/Chat.tsx';
|
||||
import { Points } from './screens/Points.tsx';
|
||||
|
||||
const ROOTS = ['/', '/orders', '/services', '/perks', '/profile', '/docs'];
|
||||
|
||||
@@ -65,6 +66,7 @@ function Routes({ segment }: { segment: 'MOVING' | 'LIVING' }) {
|
||||
else if (path === '/profile') screen = <Profile />;
|
||||
else if (path === '/membership') screen = <Membership />;
|
||||
else if (path === '/chat') screen = <Chat />;
|
||||
else if (path === '/points') screen = <Points />;
|
||||
else screen = segment === 'MOVING' ? <Move /> : <Home />;
|
||||
const withNav = ROOTS.includes(path);
|
||||
return (
|
||||
|
||||
@@ -933,3 +933,105 @@
|
||||
.chat-compose__send:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* барики в форме заказа */
|
||||
.points-toggle {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--bc-perks, #f6e3b4);
|
||||
cursor: pointer;
|
||||
}
|
||||
.points-toggle[data-off] {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.points-toggle input {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 1px;
|
||||
accent-color: var(--bc-plum);
|
||||
flex: none;
|
||||
}
|
||||
.points-toggle small {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ───── барики ───── */
|
||||
.points-hero {
|
||||
gap: 6px;
|
||||
}
|
||||
.points-hero__num {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 46px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.points-hero__ghost {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.points-rates {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.points-rates th,
|
||||
.points-rates td {
|
||||
padding: 6px 4px;
|
||||
text-align: right;
|
||||
}
|
||||
.points-rates th:first-child,
|
||||
.points-rates td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
.points-rates th {
|
||||
font-weight: 600;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.points-rates [data-on] {
|
||||
font-weight: 800;
|
||||
opacity: 1;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.points-dots {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.points-dots span {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--bc-bg);
|
||||
border: 2px solid rgba(74, 16, 40, 0.15);
|
||||
}
|
||||
.points-dots span[data-mark] {
|
||||
border-color: var(--bc-plum);
|
||||
}
|
||||
.points-dots span[data-on] {
|
||||
background: var(--bc-plum);
|
||||
border-color: var(--bc-plum);
|
||||
}
|
||||
.points-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid rgba(74, 16, 40, 0.08);
|
||||
}
|
||||
.points-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.points-plus {
|
||||
color: var(--bc-home-ink, #2f5b1f);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.points-minus {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -112,9 +112,15 @@ export function OrderDetail({ id }: { id: string }) {
|
||||
<span>−{amd(o.discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{o.pointsUsed > 0 && (
|
||||
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
|
||||
<span>{t('client.points.paidLine')}</span>
|
||||
<span>−{amd(o.pointsUsed)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bc-between total">
|
||||
<span>{t('client.order.total')}</span>
|
||||
<span>{amd(o.totalPrice)}</span>
|
||||
<span>{amd(o.totalPrice - o.pointsUsed)}</span>
|
||||
</div>
|
||||
{o.cancelFee > 0 && (
|
||||
<div className="bc-between">
|
||||
|
||||
@@ -31,6 +31,8 @@ export function OrderForm({ code }: { code: string }) {
|
||||
const [promoInput, setPromoInput] = useState('');
|
||||
const [promo, setPromo] = useState('');
|
||||
const [promoError, setPromoError] = useState<string | null>(null);
|
||||
// барики: списать в этом заказе (с промокодом нельзя)
|
||||
const [usePts, setUsePts] = useState(false);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
// дефолты услуги, затем — параметры повторяемого заказа (ORD-7)
|
||||
@@ -132,10 +134,13 @@ export function OrderForm({ code }: { code: string }) {
|
||||
paymentMethodId: methodId || undefined,
|
||||
repeatOfId: repeatId ?? undefined,
|
||||
promoCode: q?.promo ? promo : undefined,
|
||||
usePoints: ptsUsed || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const q = quote.data;
|
||||
const ptsAvailable = !!q?.points.allowed && !q.promo;
|
||||
const ptsUsed = usePts && ptsAvailable ? q!.points.max : 0;
|
||||
return (
|
||||
<main className="app app--plain">
|
||||
<PageHead title={t(`services.${code}.name`)} />
|
||||
@@ -294,6 +299,27 @@ export function OrderForm({ code }: { code: string }) {
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
{q && q.points.balance > 0 && (
|
||||
<label className="points-toggle" data-off={!ptsAvailable || undefined}>
|
||||
<input type="checkbox" checked={usePts && ptsAvailable} disabled={!ptsAvailable} onChange={(e) => setUsePts(e.target.checked)} />
|
||||
<span>
|
||||
<b>{t('client.points.useInOrder', { amount: amd(q.points.max).replace(/\s?֏$/, '') })}</b>
|
||||
<small>
|
||||
{q.promo
|
||||
? t('client.points.notWithPromo')
|
||||
: !q.points.allowed
|
||||
? t('client.points.belowMin', { min: amd(q.points.min).replace(/\s?֏$/, ''), balance: amd(q.points.balance).replace(/\s?֏$/, '') })
|
||||
: t('client.points.useHint', { pct: q.points.sharePct, balance: amd(q.points.balance).replace(/\s?֏$/, '') })}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{ptsUsed > 0 && (
|
||||
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
|
||||
<span>{t('client.points.paidLine')}</span>
|
||||
<span>−{amd(ptsUsed)}</span>
|
||||
</div>
|
||||
)}
|
||||
<PromoField
|
||||
value={promoInput}
|
||||
onChange={(v) => {
|
||||
@@ -325,7 +351,7 @@ export function OrderForm({ code }: { code: string }) {
|
||||
{t('client.order.total')}
|
||||
</span>
|
||||
<span className="bc-num" style={{ fontSize: 22 }}>
|
||||
{q ? amd(q.totalPrice) : '…'}
|
||||
{q ? amd(q.totalPrice - ptsUsed) : '…'}
|
||||
</span>
|
||||
</div>
|
||||
{slot && (
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge, Button, Card, Center, Field, Icon, Sheet, Spinner } from '@barev/ui';
|
||||
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { LEVEL_NAME, PageHead, type Level } from '../lib.tsx';
|
||||
|
||||
/** Экран «Барики»: баланс, ожидание, сгорание, ставки по уровню, серия уборок, маршрут, история */
|
||||
export function Points() {
|
||||
const { t, dt, locale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const p = useQuery(trpc.client.points.summary.queryOptions());
|
||||
const history = useQuery(trpc.client.points.history.queryOptions());
|
||||
const [pay, setPay] = useState(false);
|
||||
const n = (v: number) => new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale).format(v);
|
||||
const date = (d: Date) => dt(d, { day: 'numeric', month: 'long' });
|
||||
const name = (m: Record<string, string> | null | undefined) => (m ? m[locale] || m.ru || m.en || '' : '');
|
||||
|
||||
if (!p.data)
|
||||
return (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
);
|
||||
const d = p.data;
|
||||
const tier = d.tier as Level;
|
||||
return (
|
||||
<main className="app app--plain">
|
||||
<PageHead title={t('client.points.title')} />
|
||||
|
||||
<Card tone="dark" className="bc-stack points-hero">
|
||||
<span className="bc-small" style={{ opacity: 0.75 }}>
|
||||
{t('client.points.balance')}
|
||||
</span>
|
||||
<div className="points-hero__num">{n(d.balance)}</div>
|
||||
<span className="bc-small" style={{ opacity: 0.85 }}>
|
||||
{t('client.points.equals', { amount: n(Math.max(0, d.balance)) })}
|
||||
</span>
|
||||
{d.pending > 0 && <span className="bc-small">⏳ {t('client.points.pending', { amount: n(d.pending), date: d.pendingNextAt ? date(d.pendingNextAt) : '—' })}</span>}
|
||||
{d.burnAt && <span className="bc-small">🔥 {t('client.points.burn', { amount: n(d.burnAmount), date: date(d.burnAt) })}</span>}
|
||||
<div className="bc-row" style={{ gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
<Button size="sm" variant="light" disabled={d.balance < d.redeem.min} onClick={() => setPay(true)}>
|
||||
{t('client.points.pay')}
|
||||
</Button>
|
||||
<a className="bc-btn bc-btn--outline bc-btn--sm points-hero__ghost" href="#/profile?code=1">
|
||||
{t('client.points.enterCode')}
|
||||
</a>
|
||||
</div>
|
||||
{d.openRequests > 0 && <span className="bc-small">{t('client.points.requestPending')}</span>}
|
||||
</Card>
|
||||
|
||||
<Card className="bc-stack">
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h3">{t('client.points.rates')}</h2>
|
||||
<Badge>{LEVEL_NAME[tier]}</Badge>
|
||||
</div>
|
||||
<table className="points-rates">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th data-on={tier === 'BAREV' || undefined}>Barev</th>
|
||||
<th data-on={tier === 'BAREV_PLUS' || undefined}>Barev+</th>
|
||||
<th data-on={tier === 'ELITE' || undefined}>Elite</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.rates.map((r) => (
|
||||
<tr key={r.code}>
|
||||
<td>{name(r.name)}</td>
|
||||
<td data-on={tier === 'BAREV' || undefined}>{r.barev}%</td>
|
||||
<td data-on={tier === 'BAREV_PLUS' || undefined}>{r.plus}%</td>
|
||||
<td data-on={tier === 'ELITE' || undefined}>{r.elite}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tier === 'BAREV' && d.plusWouldGet > 0 && (
|
||||
<a href="#/membership" className="bc-small" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
|
||||
{t('client.points.plusWouldGet', { amount: n(d.plusWouldGet) })}
|
||||
</a>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="bc-stack">
|
||||
<h2 className="bc-h3">{t('client.points.streak')}</h2>
|
||||
<div className="points-dots" aria-hidden="true">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<span key={i} data-on={d.streak.count >= i || undefined} data-mark={i === 3 || i === 6 || undefined} />
|
||||
))}
|
||||
</div>
|
||||
<p className="bc-small" style={{ margin: 0 }}>
|
||||
{d.streak.next
|
||||
? t('client.points.streakLeft', { left: d.streak.next - d.streak.count, bonus: d.streak.next === 3 ? 2 : 3, days: d.streak.gapDays })
|
||||
: t('client.points.streakMax', { bonus: d.streak.bonusPct })}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{d.route && (
|
||||
<Card tone="home" className="bc-stack">
|
||||
<h2 className="bc-h3">{t('client.points.route')}</h2>
|
||||
<div className="points-dots" aria-hidden="true">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<span key={i} data-on={d.route!.done >= i || undefined} />
|
||||
))}
|
||||
</div>
|
||||
<p className="bc-small" style={{ margin: 0 }}>
|
||||
{t('client.points.routeText', { done: d.route.done, amount: n(d.route.amount), date: date(d.route.until) })}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('client.points.history')}</h2>
|
||||
{!history.data ? (
|
||||
<Spinner />
|
||||
) : history.data.length === 0 ? (
|
||||
<p className="bc-muted" style={{ margin: 0 }}>
|
||||
{t('client.points.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<Card tight className="bc-stack" style={{ gap: 0 }}>
|
||||
{history.data.map((r) => (
|
||||
<div key={`${r.type}:${r.id}`} className="points-row">
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{r.type === 'bonus' ? name(r.bonus) : r.type === 'accrual' ? name(r.category) : t(`client.points.type.${r.type}`)}
|
||||
{r.orderRef ? <span className="bc-muted"> {r.orderRef}</span> : null}
|
||||
</div>
|
||||
<div className="bc-small bc-muted">
|
||||
{dt(r.at, { day: 'numeric', month: 'short' })} · {t(`client.points.status.${r.status}`)}
|
||||
{r.status === 'PENDING' && r.availableAt ? ` · ${t('client.points.availableFrom', { date: date(r.availableAt) })}` : ''}
|
||||
{r.expiresAt && r.status === 'ACTIVE' ? ` · ${t('client.points.until', { date: date(r.expiresAt) })}` : ''}
|
||||
</div>
|
||||
{r.comment && <div className="bc-small">💬 {r.comment}</div>}
|
||||
</div>
|
||||
<b className={r.amount > 0 ? 'points-plus' : 'points-minus'}>
|
||||
{r.amount > 0 ? '+' : '−'}
|
||||
{n(Math.abs(r.amount))}
|
||||
</b>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
{pay && <PaySheet balance={d.balance} sharePct={d.redeem.sharePct} min={d.redeem.min} onClose={() => setPay(false)} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Оплатить бариками: выбрать заказ или ввести сумму — запрос уходит команде */
|
||||
function PaySheet({ balance, sharePct, min, onClose }: { balance: number; sharePct: number; min: number; onClose: () => void }) {
|
||||
const { t, amd, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const orders = useQuery(trpc.client.orders.list.queryOptions());
|
||||
const [orderId, setOrderId] = useState<string>('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const order = orders.data?.active.find((o) => o.id === orderId);
|
||||
const max = order ? Math.min(balance, Math.floor(((order.totalPrice - order.pointsUsed) * sharePct) / 100)) : balance;
|
||||
const value = Number(amount) || 0;
|
||||
const send = useMutation(
|
||||
trpc.client.points.request.mutationOptions({
|
||||
onSuccess: () => {
|
||||
haptic.ok();
|
||||
toast(t('client.points.requested'));
|
||||
void qc.invalidateQueries();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const payable = orders.data?.active.filter((o) => o.paymentStatus === 'UNPAID') ?? [];
|
||||
return (
|
||||
<Sheet open onClose={onClose} label={t('client.points.pay')}>
|
||||
<div className="bc-stack">
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h2">{t('client.points.pay')}</h2>
|
||||
<button className="back" onClick={onClose} aria-label={t('common.close')}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="bc-small" style={{ margin: 0 }}>
|
||||
{t('client.points.payHint', { pct: sharePct, min: amd(min).replace(/\s?֏$/, '') })}
|
||||
</p>
|
||||
<Field label={t('client.points.forOrder')}>
|
||||
<select className="bc-input" value={orderId} onChange={(e) => setOrderId(e.target.value)}>
|
||||
<option value="">{t('client.points.otherOrder')}</option>
|
||||
{payable.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
#{o.number} · {t(`services.${o.serviceCode}.name`)} · {dt(o.scheduledAt, { day: 'numeric', month: 'short' })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('client.points.amount')}>
|
||||
<input className="bc-input" inputMode="numeric" value={amount} placeholder={String(max)} onChange={(e) => setAmount(e.target.value.replace(/\D/g, '').slice(0, 7))} />
|
||||
</Field>
|
||||
<p className="bc-small bc-muted" style={{ margin: '-6px 0 0' }}>
|
||||
{t('client.points.max', { max: amd(max).replace(/\s?֏$/, '') })}
|
||||
</p>
|
||||
{!order && (
|
||||
<Field label={t('client.points.note')}>
|
||||
<input className="bc-input" value={note} maxLength={300} placeholder={t('client.points.notePh')} onChange={(e) => setNote(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
<Button block disabled={send.isPending || value < min || value > max} onClick={() => send.mutate({ amount: value, orderId: orderId || undefined, note: note.trim() || undefined })}>
|
||||
{t('client.points.send')}
|
||||
</Button>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,14 @@ export function Profile() {
|
||||
<LanguagePicker />
|
||||
|
||||
<nav className="links">
|
||||
<a href="#/points">
|
||||
<Icon name="star" size={20} />
|
||||
<span>
|
||||
{t('client.points.title')}
|
||||
{me.data ? <b style={{ marginLeft: 8 }}>{new Intl.NumberFormat('ru-RU').format(me.data.points)}</b> : null}
|
||||
</span>
|
||||
<Icon name="arrow" size={18} />
|
||||
</a>
|
||||
<a href="#/orders">
|
||||
<Icon name="clock" size={20} />
|
||||
<span>{t('client.orders.title')}</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Center, Icon, Spinner } from '@barev/ui';
|
||||
import { I18nProvider, match, navigate, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { forgetWebLogin, isWeb } from './auth.ts';
|
||||
import { Company } from './screens/Company.tsx';
|
||||
import { History } from './screens/History.tsx';
|
||||
import { Job } from './screens/Job.tsx';
|
||||
@@ -15,10 +16,14 @@ export function App({ hasInitData }: { hasInitData: boolean }) {
|
||||
const me = useQuery({ ...trpc.pro.me.queryOptions(), enabled: hasInitData });
|
||||
const locale = pickLocale(me.data?.locale, telegramLanguage(), navigator.language);
|
||||
const status = me.error?.data?.httpStatus;
|
||||
useEffect(() => {
|
||||
if (status === 401 && isWeb()) forgetWebLogin();
|
||||
}, [status]);
|
||||
return (
|
||||
<I18nProvider initial={locale}>
|
||||
{!hasInitData || status === 401 ? (
|
||||
<Message titleKey="pro.openInTelegram.title" textKey="pro.openInTelegram.text" />
|
||||
// в браузере без входа или с устаревшим входом — подсказка, как получить ссылку из бота
|
||||
<Message titleKey="pro.openInTelegram.title" textKey={isWeb() ? 'pro.web.loginHint' : 'pro.openInTelegram.text'} />
|
||||
) : status === 403 ? (
|
||||
<Message titleKey="pro.notLinked.title" textKey="pro.notLinked.text" />
|
||||
) : me.isPending ? (
|
||||
|
||||
@@ -203,3 +203,60 @@
|
||||
.invite .bc-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* кабинет менеджера партнёра: в браузере на компьютере — шире, карточки в две колонки */
|
||||
.app--wide {
|
||||
max-width: 1040px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
.cab-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.app--wide .cab-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.cab-row2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.cab-link {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 6px 0;
|
||||
color: var(--bc-plum);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.cab-copy {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 8px 8px 12px;
|
||||
border-radius: 16px;
|
||||
background: var(--bc-bg);
|
||||
}
|
||||
.cab-copy code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cab-switch {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cab-switch input {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--bc-plum);
|
||||
}
|
||||
@@ -1,5 +1,54 @@
|
||||
import { getInitData } from '@barev/web';
|
||||
|
||||
/**
|
||||
* Внутри Telegram запросы подписаны initData. В браузере (кабинет менеджера партнёра на компьютере)
|
||||
* — токен: бот выдаёт одноразовую ссылку ?login=<код>, страница меняет её на токен и хранит в localStorage.
|
||||
*/
|
||||
const initData = getInitData();
|
||||
export const hasInitData = !!initData;
|
||||
export const authHeader = () => ({ authorization: `tma ${initData}` });
|
||||
const TOKEN_KEY = 'barev.pro.web';
|
||||
|
||||
function readToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
let webToken = initData ? null : readToken();
|
||||
|
||||
export const hasAuth = () => !!initData || !!webToken;
|
||||
export const isWeb = () => !initData;
|
||||
export const authHeader = (): Record<string, string> => (initData ? { authorization: `tma ${initData}` } : webToken ? { authorization: `pro ${webToken}` } : {});
|
||||
|
||||
export function forgetWebLogin() {
|
||||
webToken = null;
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* приватный режим */
|
||||
}
|
||||
}
|
||||
|
||||
/** Ссылка из бота: обменять одноразовый код на токен до первого запроса */
|
||||
export async function consumeLoginLink(): Promise<void> {
|
||||
if (initData) return;
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get('login');
|
||||
if (!code) return;
|
||||
url.searchParams.delete('login');
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
try {
|
||||
const res = await fetch('/trpc/pro.webLogin', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ json: { code } }) });
|
||||
const body = (await res.json()) as { result?: { data?: { json?: { token: string } } } };
|
||||
const token = body.result?.data?.json?.token;
|
||||
if (!token) return;
|
||||
webToken = token;
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
} catch {
|
||||
/* без localStorage вход действует до закрытия вкладки */
|
||||
}
|
||||
} catch {
|
||||
/* сеть — покажем экран «откройте ссылку из бота» */
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,21 @@ import { ApiProvider, loadTextOverrides, initTelegram, ToastProvider } from '@ba
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import { authHeader, hasInitData } from './auth.ts';
|
||||
import { authHeader, consumeLoginLink, hasAuth } from './auth.ts';
|
||||
|
||||
initTelegram();
|
||||
// названия и описания услуг, изменённые в админке
|
||||
void loadTextOverrides();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApiProvider headers={authHeader}>
|
||||
<ToastProvider>
|
||||
<App hasInitData={hasInitData} />
|
||||
</ToastProvider>
|
||||
</ApiProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
// в браузере: ссылка из бота ?login=… меняется на токен до первого запроса
|
||||
void consumeLoginLink().then(() => {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApiProvider headers={authHeader}>
|
||||
<ToastProvider>
|
||||
<App hasInitData={hasAuth()} />
|
||||
</ToastProvider>
|
||||
</ApiProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge, Button, Card, Center, Chip, Icon, Sheet, Spinner } from '@barev/ui';
|
||||
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Badge, Button, Card, Center, Chip, Field, Icon, Segmented, Sheet, Spinner } from '@barev/ui';
|
||||
import { confirmDialog, errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { forgetWebLogin, isWeb } from '../auth.ts';
|
||||
import { JobRow, PageHead } from '../lib.tsx';
|
||||
|
||||
/** PRO-7: диспетчер компании распределяет заказы, назначенные на компанию */
|
||||
type Tab = 'orders' | 'staff' | 'money' | 'settings';
|
||||
type StaffCheck = 'ok' | 'busy' | 'gap' | 'limit' | 'notLinked';
|
||||
|
||||
/** Кабинет менеджера партнёра: заказы и распределение, сотрудники, доход, настройки */
|
||||
export function Company({ companies }: { companies: { id: string; name: string }[] }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const [companyId, setCompanyId] = useState(companies[0]!.id);
|
||||
const board = useQuery({ ...trpc.pro.dispatcher.board.queryOptions({ companyId }), refetchInterval: 15_000 });
|
||||
const [picking, setPicking] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>('orders');
|
||||
const cab = useCabinet(companyId);
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<PageHead title={board.data?.company.name ?? t('pro.company.title')} back={false} />
|
||||
<main className={isWeb() ? 'app app--wide' : 'app'}>
|
||||
<PageHead title={cab.data?.company.name ?? t('pro.company.title')} back={false} />
|
||||
{companies.length > 1 && (
|
||||
<div className="bc-chips">
|
||||
{companies.map((c) => (
|
||||
@@ -24,111 +28,587 @@ export function Company({ companies }: { companies: { id: string; name: string }
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!board.data ? (
|
||||
<Segmented
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
label={t('pro.company.title')}
|
||||
options={[
|
||||
{ value: 'orders', label: t('pro.cab.tab.orders') + (cab.data?.pending.length ? ` · ${cab.data.pending.length}` : '') },
|
||||
{ value: 'staff', label: t('pro.cab.tab.staff') },
|
||||
{ value: 'money', label: t('pro.cab.tab.money') },
|
||||
{ value: 'settings', label: t('pro.cab.tab.settings') },
|
||||
]}
|
||||
/>
|
||||
{!cab.data ? (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
) : tab === 'orders' ? (
|
||||
<Orders companyId={companyId} data={cab.data} />
|
||||
) : tab === 'staff' ? (
|
||||
<Staff companyId={companyId} data={cab.data} />
|
||||
) : tab === 'money' ? (
|
||||
<Money data={cab.data} />
|
||||
) : (
|
||||
<>
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">
|
||||
{t('pro.company.pending')} · {board.data.pending.length}
|
||||
</h2>
|
||||
{board.data.pending.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noPending')}</p>}
|
||||
{board.data.pending.map((o) => (
|
||||
<Card key={o.id} tone="pink" tight className="bc-stack" style={{ gap: 8 }}>
|
||||
<JobRow j={o} />
|
||||
<Button size="sm" onClick={() => setPicking(o.id)}>
|
||||
{t('pro.company.assign')}
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.company.assigned')}</h2>
|
||||
{board.data.assigned.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noAssigned')}</p>}
|
||||
{board.data.assigned.map((o) => (
|
||||
<JobRow key={o.id} j={o} right={<span className="bc-small" style={{ fontWeight: 600 }}>{o.providerName}</span>} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.company.staff')}</h2>
|
||||
<Card tight className="bc-stack" style={{ gap: 6 }}>
|
||||
{board.data.staff.map((p) => (
|
||||
<div key={p.id} className="bc-between" style={{ minHeight: 40 }}>
|
||||
<span style={{ fontWeight: 600 }}>{p.name}</span>
|
||||
<span className="bc-row bc-small">
|
||||
{p.languages.map((l) => l.toUpperCase()).join(' ')}
|
||||
<Badge style={{ background: p.onShift ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(p.onShift ? 'pro.today.onShift' : 'pro.today.offShift')}</Badge>
|
||||
{!p.linked && <Badge style={{ background: 'var(--bc-docs)' }}>{t('pro.company.notLinked')}</Badge>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
</>
|
||||
<Settings companyId={companyId} data={cab.data} />
|
||||
)}
|
||||
{picking && <StaffSheet companyId={companyId} orderId={picking} onClose={() => setPicking(null)} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StaffSheet({ companyId, orderId, onClose }: { companyId: string; orderId: string; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
function useCabinet(companyId: string) {
|
||||
const trpc = useTRPC();
|
||||
return useQuery({ ...trpc.pro.dispatcher.cabinet.queryOptions({ companyId }), refetchInterval: 15_000 });
|
||||
}
|
||||
type Cabinet = NonNullable<ReturnType<typeof useCabinet>['data']>;
|
||||
|
||||
function useAction() {
|
||||
const { t } = useI18n();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
return {
|
||||
onSuccess: (msg?: string) => () => {
|
||||
haptic.ok();
|
||||
if (msg) toast(msg);
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: unknown) => toast(errorText(t, e), 'error'),
|
||||
};
|
||||
}
|
||||
|
||||
// ───── заказы ─────
|
||||
|
||||
function Orders({ companyId, data }: { companyId: string; data: Cabinet }) {
|
||||
const { t, dt, amd } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const act = useAction();
|
||||
const [picking, setPicking] = useState<string | null>(null);
|
||||
const decline = useMutation(trpc.pro.dispatcher.decline.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.declined')), onError: act.onError }));
|
||||
const unassign = useMutation(trpc.pro.dispatcher.unassign.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.unassigned')), onError: act.onError }));
|
||||
return (
|
||||
<>
|
||||
{data.company.autoAssign && (
|
||||
<Card tight tone="blue" className="bc-row" style={{ gap: 10 }}>
|
||||
<Icon name="repeat" size={20} />
|
||||
<span className="bc-small">{t('pro.cab.autoOn')}</span>
|
||||
</Card>
|
||||
)}
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">
|
||||
{t('pro.company.pending')} · {data.pending.length}
|
||||
</h2>
|
||||
{data.pending.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noPending')}</p>}
|
||||
<div className="cab-grid">
|
||||
{data.pending.map((o) => (
|
||||
<Card key={o.id} tone="pink" tight className="bc-stack" style={{ gap: 8 }}>
|
||||
<JobRow j={o} right={<b className="bc-small">{dt(o.scheduledAt, { day: 'numeric', month: 'short' })}</b>} />
|
||||
<div className="bc-small" style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
|
||||
<span>
|
||||
{t('pro.cab.income')}: <b>{amd(o.income)}</b>
|
||||
</span>
|
||||
{o.deadline && !o.offeredTo && <span>{t('pro.cab.deadline', { time: dt(o.deadline, { hour: '2-digit', minute: '2-digit' }) })}</span>}
|
||||
{o.offeredTo && <span>{t('pro.cab.offeredTo', { name: o.offeredTo })}</span>}
|
||||
</div>
|
||||
<div className="bc-row" style={{ gap: 8 }}>
|
||||
<Button size="sm" onClick={() => setPicking(o.id)}>
|
||||
{t('pro.company.assign')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={decline.isPending}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog(t('pro.cab.declineConfirm'))) decline.mutate({ companyId, orderId: o.id });
|
||||
}}
|
||||
>
|
||||
{t('pro.cab.decline')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.company.assigned')}</h2>
|
||||
{data.assigned.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noAssigned')}</p>}
|
||||
<div className="cab-grid">
|
||||
{data.assigned.map((o) => (
|
||||
<Card key={o.id} tight className="bc-stack" style={{ gap: 6 }}>
|
||||
<JobRow j={o} right={<b className="bc-small">{dt(o.scheduledAt, { day: 'numeric', month: 'short' })}</b>} />
|
||||
<div className="bc-between bc-small">
|
||||
<span>
|
||||
<Icon name="user" size={16} /> <b>{o.providerName}</b> · {t(`status.${o.flow}.${o.status}`)}
|
||||
</span>
|
||||
{o.canUnassign && (
|
||||
<button
|
||||
type="button"
|
||||
className="cab-link"
|
||||
disabled={unassign.isPending}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog(t('pro.cab.unassignConfirm', { name: o.providerName ?? '' }))) unassign.mutate({ companyId, orderId: o.id });
|
||||
}}
|
||||
>
|
||||
{t('pro.cab.reassign')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{picking && <AssignSheet companyId={companyId} orderId={picking} onClose={() => setPicking(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECK_TONE: Record<StaffCheck, string> = { ok: 'var(--bc-home)', notLinked: 'var(--bc-docs)', gap: 'var(--bc-docs)', limit: 'var(--bc-docs)', busy: 'var(--bc-bg)' };
|
||||
|
||||
function AssignSheet({ companyId, orderId, onClose }: { companyId: string; orderId: string; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const act = useAction();
|
||||
const staff = useQuery(trpc.pro.dispatcher.staffForOrder.queryOptions({ companyId, orderId }));
|
||||
const assign = useMutation(
|
||||
trpc.pro.dispatcher.assign.mutationOptions({
|
||||
onSuccess: () => {
|
||||
haptic.ok();
|
||||
toast(t('pro.company.assigned_ok'));
|
||||
void qc.invalidateQueries();
|
||||
act.onSuccess(t('pro.company.assigned_ok'))();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
onError: act.onError,
|
||||
}),
|
||||
);
|
||||
// свободные и говорящие на языке клиента — выше
|
||||
const sorted = [...(staff.data ?? [])].sort((a, b) => Number(b.free) - Number(a.free) || Number(b.speaksClientLanguage) - Number(a.speaksClientLanguage) || a.load - b.load);
|
||||
return (
|
||||
<Sheet open onClose={onClose} label={t('pro.company.assign')}>
|
||||
<div className="bc-stack">
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h2">{t('pro.company.chooseStaff')}</h2>
|
||||
<button className="back" onClick={onClose} aria-label={t('common.close')}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<SheetHead title={t('pro.company.chooseStaff')} onClose={onClose} />
|
||||
{staff.data && <p className="bc-small bc-muted" style={{ margin: 0 }}>{t('pro.cab.gapHint', { min: staff.data.gap })}</p>}
|
||||
{!staff.data ? (
|
||||
<Spinner />
|
||||
) : staff.data.rows.length === 0 ? (
|
||||
<p className="bc-muted">{t('pro.cab.noStaff')}</p>
|
||||
) : (
|
||||
sorted.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="job"
|
||||
style={{ border: 'none', textAlign: 'left', cursor: p.free ? 'pointer' : 'not-allowed', opacity: p.free ? 1 : 0.5 }}
|
||||
disabled={!p.free || assign.isPending}
|
||||
onClick={() => assign.mutate({ companyId, orderId, providerId: p.id })}
|
||||
>
|
||||
<div className="job__main">
|
||||
<div style={{ fontWeight: 700 }}>{p.name}</div>
|
||||
<div className="bc-small bc-muted">
|
||||
{p.languages.map((l) => l.toUpperCase()).join(' ')} · {t('pro.company.load', { count: p.load })}
|
||||
staff.data.rows.map((p) => {
|
||||
const can = p.check !== 'busy';
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="job"
|
||||
style={{ border: 'none', textAlign: 'left', cursor: can ? 'pointer' : 'not-allowed', opacity: can ? 1 : 0.5 }}
|
||||
disabled={!can || assign.isPending}
|
||||
onClick={async () => {
|
||||
if (p.check !== 'ok' && !(await confirmDialog(t(`pro.cab.check.${p.check}_confirm`)))) return;
|
||||
assign.mutate({ companyId, orderId, providerId: p.id });
|
||||
}}
|
||||
>
|
||||
<div className="job__main">
|
||||
<div style={{ fontWeight: 700 }}>{p.name}</div>
|
||||
<div className="bc-small bc-muted">
|
||||
{p.languages.map((l) => l.toUpperCase()).join(' ')} · {t('pro.cab.dayLoad', { n: p.dayOrders, max: p.maxOrdersPerDay ?? '∞' })} · {t('pro.cab.weekLoad', { n: p.weekOrders })}
|
||||
{p.rating != null && ` · ★ ${p.rating}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="bc-row">
|
||||
{p.speaksClientLanguage && <Badge style={{ background: 'var(--bc-home)' }}>{t('pro.company.speaksLanguage')}</Badge>}
|
||||
<Badge style={{ background: p.free ? 'var(--bc-blue)' : 'var(--bc-bg)' }}>{t(p.free ? 'pro.company.free' : 'pro.company.busy')}</Badge>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
<span className="bc-row" style={{ flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{p.language && <Badge style={{ background: 'var(--bc-home)' }}>{t('pro.company.speaksLanguage')}</Badge>}
|
||||
{p.district && <Badge style={{ background: 'var(--bc-blue)' }}>{t('pro.cab.district')}</Badge>}
|
||||
<Badge style={{ background: CHECK_TONE[p.check] }}>{t(`pro.cab.check.${p.check}`)}</Badge>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHead({ title, onClose }: { title: ReactNode; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h2">{title}</h2>
|
||||
<button className="back" onClick={onClose} aria-label={t('common.close')}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── сотрудники ─────
|
||||
|
||||
type StaffRow = Cabinet['staff'][number];
|
||||
|
||||
function Staff({ companyId, data }: { companyId: string; data: Cabinet }) {
|
||||
const { t } = useI18n();
|
||||
const [edit, setEdit] = useState<StaffRow | 'new' | null>(null);
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h3">
|
||||
{t('pro.company.staff')} · {data.staff.filter((s) => s.active).length}
|
||||
</h2>
|
||||
<Button size="sm" onClick={() => setEdit('new')}>
|
||||
<Icon name="plus" size={18} /> {t('pro.cab.addStaff')}
|
||||
</Button>
|
||||
</div>
|
||||
{data.staff.length === 0 && <p className="bc-muted">{t('pro.cab.noStaff')}</p>}
|
||||
<div className="cab-grid">
|
||||
{data.staff.map((p) => (
|
||||
<button key={p.id} type="button" className="job" style={{ border: 'none', textAlign: 'left', cursor: 'pointer', opacity: p.active ? 1 : 0.5 }} onClick={() => setEdit(p)}>
|
||||
<div className="job__main">
|
||||
<div style={{ fontWeight: 700 }}>{p.name}</div>
|
||||
<div className="bc-small bc-muted">
|
||||
{p.languages.map((l) => l.toUpperCase()).join(' ')} · {t('pro.cab.todayLoad', { n: p.today, max: p.maxOrdersPerDay ?? '∞' })}
|
||||
{p.maxHoursPerDay ? ` · ${t('pro.cab.hoursMax', { n: p.maxHoursPerDay })}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className="bc-row">
|
||||
{!p.active ? (
|
||||
<Badge style={{ background: 'var(--bc-bg)' }}>{t('pro.cab.off')}</Badge>
|
||||
) : !p.linked ? (
|
||||
<Badge style={{ background: 'var(--bc-docs)' }}>{t('pro.company.notLinked')}</Badge>
|
||||
) : (
|
||||
<Badge style={{ background: p.onShift ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(p.onShift ? 'pro.today.onShift' : 'pro.today.offShift')}</Badge>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{edit && <StaffSheet companyId={companyId} districts={data.districts} staff={edit === 'new' ? null : edit} onClose={() => setEdit(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||
const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
||||
const toMin = (s: string) => {
|
||||
const [h, m] = s.split(':').map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
};
|
||||
|
||||
function StaffSheet({ companyId, districts, staff, onClose }: { companyId: string; districts: { id: string; code: string }[]; staff: StaffRow | null; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const act = useAction();
|
||||
const first = staff?.schedule[0];
|
||||
const [f, setF] = useState({
|
||||
name: staff?.name ?? '',
|
||||
phone: staff?.phone ?? '',
|
||||
languages: (staff?.languages ?? ['ru']) as Locale[],
|
||||
districtIds: staff?.districtIds ?? [],
|
||||
days: staff ? [...new Set(staff.schedule.map((s) => s.weekday))] : [0, 1, 2, 3, 4, 5],
|
||||
from: hhmm(first?.startMinute ?? 9 * 60),
|
||||
to: hhmm(first?.endMinute ?? 19 * 60),
|
||||
maxOrders: staff?.maxOrdersPerDay ? String(staff.maxOrdersPerDay) : '',
|
||||
maxHours: staff?.maxHoursPerDay ? String(staff.maxHoursPerDay) : '',
|
||||
});
|
||||
const [link, setLink] = useState<string | null>(staff?.inviteLink ?? null);
|
||||
const toggle = <T,>(list: T[], v: T) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
|
||||
const payload = () => ({
|
||||
name: f.name,
|
||||
phone: f.phone || null,
|
||||
languages: f.languages,
|
||||
districtIds: f.districtIds,
|
||||
schedule: f.days.map((weekday) => ({ weekday, startMinute: toMin(f.from), endMinute: toMin(f.to) })),
|
||||
maxOrdersPerDay: Number(f.maxOrders) || null,
|
||||
maxHoursPerDay: Number(f.maxHours) || null,
|
||||
});
|
||||
const add = useMutation(
|
||||
trpc.pro.dispatcher.addStaff.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
act.onSuccess(t('pro.cab.staffAdded'))();
|
||||
setLink(r.inviteLink);
|
||||
},
|
||||
onError: act.onError,
|
||||
}),
|
||||
);
|
||||
const save = useMutation(
|
||||
trpc.pro.dispatcher.updateStaff.mutationOptions({
|
||||
onSuccess: () => {
|
||||
act.onSuccess(t('pro.cab.saved'))();
|
||||
onClose();
|
||||
},
|
||||
onError: act.onError,
|
||||
}),
|
||||
);
|
||||
const reset = useMutation(trpc.pro.dispatcher.resetStaffInvite.mutationOptions({ onSuccess: (r) => setLink(r.inviteLink), onError: act.onError }));
|
||||
const created = !staff && !!link;
|
||||
|
||||
return (
|
||||
<Sheet open onClose={onClose} label={staff ? staff.name : t('pro.cab.addStaff')}>
|
||||
<div className="bc-stack">
|
||||
<SheetHead title={staff ? staff.name : t('pro.cab.addStaff')} onClose={onClose} />
|
||||
{created ? (
|
||||
<>
|
||||
<p style={{ margin: 0 }}>{t('pro.cab.inviteText')}</p>
|
||||
<CopyBox url={link!} />
|
||||
<Button block onClick={onClose}>
|
||||
{t('pro.cab.done')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t('pro.cab.f.name')}>
|
||||
<input className="bc-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.phone')}>
|
||||
<input className="bc-input" type="tel" value={f.phone} maxLength={30} onChange={(e) => setF({ ...f, phone: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.languages')}>
|
||||
<div className="bc-chips">
|
||||
{LOCALES.map((l) => (
|
||||
<Chip key={l} pressed={f.languages.includes(l)} onClick={() => setF({ ...f, languages: toggle(f.languages, l) })}>
|
||||
{l.toUpperCase()}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.districts')}>
|
||||
<div className="bc-chips">
|
||||
{districts.map((d) => (
|
||||
<Chip key={d.id} pressed={f.districtIds.includes(d.id)} onClick={() => setF({ ...f, districtIds: toggle(f.districtIds, d.id) })}>
|
||||
{t(`districts.${d.code}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.days')}>
|
||||
<div className="bc-chips">
|
||||
{DAYS.map((d) => (
|
||||
<Chip key={d} pressed={f.days.includes(d)} onClick={() => setF({ ...f, days: toggle(f.days, d) })}>
|
||||
{t(`pro.cab.wd.${d}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<div className="cab-row2">
|
||||
<Field label={t('pro.cab.f.from')}>
|
||||
<input className="bc-input" type="time" step={1800} value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.to')}>
|
||||
<input className="bc-input" type="time" step={1800} value={f.to} onChange={(e) => setF({ ...f, to: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="cab-row2">
|
||||
<Field label={t('pro.cab.f.maxOrders')}>
|
||||
<input className="bc-input" inputMode="numeric" placeholder="∞" value={f.maxOrders} onChange={(e) => setF({ ...f, maxOrders: e.target.value.replace(/\D/g, '').slice(0, 2) })} />
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.maxHours')}>
|
||||
<input className="bc-input" inputMode="numeric" placeholder="∞" value={f.maxHours} onChange={(e) => setF({ ...f, maxHours: e.target.value.replace(/\D/g, '').slice(0, 2) })} />
|
||||
</Field>
|
||||
</div>
|
||||
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
||||
{t('pro.cab.f.loadHint')}
|
||||
</p>
|
||||
{staff && link && (
|
||||
<>
|
||||
<p className="bc-small" style={{ margin: 0 }}>
|
||||
{t('pro.cab.inviteText')}
|
||||
</p>
|
||||
<CopyBox url={link} />
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
block
|
||||
disabled={!f.name.trim() || !f.languages.length || toMin(f.to) <= toMin(f.from) || add.isPending || save.isPending}
|
||||
onClick={() => (staff ? save.mutate({ companyId, providerId: staff.id, staff: payload() }) : add.mutate({ companyId, staff: payload() }))}
|
||||
>
|
||||
{staff ? t('pro.cab.save') : t('pro.cab.addStaff')}
|
||||
</Button>
|
||||
{staff && (
|
||||
<div className="bc-row" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
{staff.linked && (
|
||||
<Button size="sm" variant="ghost" disabled={reset.isPending} onClick={async () => (await confirmDialog(t('pro.cab.resetConfirm'))) && reset.mutate({ companyId, providerId: staff.id })}>
|
||||
{t('pro.cab.resetInvite')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={staff.active ? 'danger' : 'outline'}
|
||||
disabled={save.isPending}
|
||||
onClick={async () => {
|
||||
if (!staff.active || (await confirmDialog(t('pro.cab.offConfirm', { name: staff.name })))) save.mutate({ companyId, providerId: staff.id, staff: payload(), active: !staff.active });
|
||||
}}
|
||||
>
|
||||
{staff.active ? t('pro.cab.turnOff') : t('pro.cab.turnOn')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyBox({ url }: { url: string }) {
|
||||
const { t } = useI18n();
|
||||
const toast = useToast();
|
||||
return (
|
||||
<div className="cab-copy">
|
||||
<code>{url}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(url);
|
||||
toast(t('pro.cab.copied'));
|
||||
}}
|
||||
>
|
||||
{t('pro.cab.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── доход ─────
|
||||
|
||||
function Money({ data }: { data: Cabinet }) {
|
||||
const { t, amd, dt, locale } = useI18n();
|
||||
const m = data.money;
|
||||
const terms = data.company.terms;
|
||||
const month = (p: string) => new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(`${p}-15T12:00:00+04:00`));
|
||||
return (
|
||||
<>
|
||||
<Card tone="dark" className="bc-stack" style={{ gap: 6 }}>
|
||||
<span className="bc-small" style={{ opacity: 0.75 }}>
|
||||
{t('pro.cab.balance')}
|
||||
</span>
|
||||
<div className="stat__value" style={{ fontSize: 30 }}>
|
||||
{amd(m.balance)}
|
||||
</div>
|
||||
<span className="bc-small" style={{ opacity: 0.85 }}>
|
||||
{t('pro.cab.accrued')}: {amd(m.accrued)} · {t('pro.cab.paidOut')}: {amd(m.paid)}
|
||||
</span>
|
||||
</Card>
|
||||
<Card tight className="bc-small">
|
||||
{terms.deal === 'COMMISSION' ? t('pro.cab.terms.COMMISSION', { pct: terms.commissionPct }) : t('pro.cab.terms.PROFIT_SHARE', { pct: terms.profitSharePct, fot: terms.fotPct, tax: terms.taxPct })}
|
||||
</Card>
|
||||
{m.months.length > 0 && (
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.cab.byMonth')}</h2>
|
||||
<Card tight className="bc-stack" style={{ gap: 6 }}>
|
||||
{m.months.map((x) => (
|
||||
<div key={x.period} className="bc-between">
|
||||
<span style={{ textTransform: 'capitalize' }}>{month(x.period)}</span>
|
||||
<span className="bc-small bc-muted">{t('pro.cab.ordersN', { n: x.orders })}</span>
|
||||
<b>{amd(x.amount)}</b>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.cab.byOrder')}</h2>
|
||||
{m.recent.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.cab.noIncome')}</p>}
|
||||
<div className="cab-grid">
|
||||
{m.recent.map((e) => (
|
||||
<Card key={e.orderId} tight className="bc-stack" style={{ gap: 2 }}>
|
||||
<div className="bc-between">
|
||||
<b>
|
||||
#{e.number} · {dt(e.at, { day: 'numeric', month: 'short' })}
|
||||
</b>
|
||||
<b>{amd(e.amount)}</b>
|
||||
</div>
|
||||
<span className="bc-small bc-muted">
|
||||
{e.staff ? `${e.staff} · ` : ''}
|
||||
{t('pro.cab.paidBy', { sum: amd(e.paid) })}
|
||||
{e.deal === 'PROFIT_SHARE' ? ` · ${t('pro.cab.minus', { fot: amd(e.fot), tax: amd(e.tax), pct: e.pct })}` : ` · ${t('pro.cab.minusCommission', { pct: e.pct })}`}
|
||||
</span>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{m.payouts.length > 0 && (
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.cab.payouts')}</h2>
|
||||
<Card tight className="bc-stack" style={{ gap: 6 }}>
|
||||
{m.payouts.map((p) => (
|
||||
<div key={p.id} className="bc-between">
|
||||
<span>{p.paidAt ? dt(p.paidAt, { day: 'numeric', month: 'long' }) : '—'}</span>
|
||||
<Badge style={{ background: p.status === 'PAID' ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(`pro.cab.payout.${p.status === 'PAID' ? 'paid' : 'pending'}`)}</Badge>
|
||||
<b>{amd(p.amount)}</b>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── настройки ─────
|
||||
|
||||
function Settings({ companyId, data }: { companyId: string; data: Cabinet }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const act = useAction();
|
||||
const c = data.company;
|
||||
const [auto, setAuto] = useState(c.autoAssign);
|
||||
const [gap, setGap] = useState(String(c.travelGapMinutes));
|
||||
const save = useMutation(trpc.pro.dispatcher.saveSettings.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.saved')), onError: act.onError }));
|
||||
const remove = useMutation(trpc.pro.dispatcher.removeManager.mutationOptions({ onSuccess: act.onSuccess(), onError: act.onError }));
|
||||
const logout = useMutation(
|
||||
trpc.pro.webLogout.mutationOptions({
|
||||
onSettled: () => {
|
||||
forgetWebLogin();
|
||||
window.location.reload();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const gapN = Number(gap) || 0;
|
||||
return (
|
||||
<>
|
||||
<Card className="bc-stack">
|
||||
<label className="cab-switch">
|
||||
<input type="checkbox" checked={auto} onChange={(e) => setAuto(e.target.checked)} />
|
||||
<span>
|
||||
<b>{t('pro.cab.auto')}</b>
|
||||
<span className="bc-small bc-muted" style={{ display: 'block' }}>
|
||||
{t('pro.cab.autoHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<Field label={t('pro.cab.gap')} error={gapN < c.minTravelGap ? t('pro.cab.gapMin', { min: c.minTravelGap }) : undefined}>
|
||||
<input className="bc-input" inputMode="numeric" value={gap} onChange={(e) => setGap(e.target.value.replace(/\D/g, '').slice(0, 3))} />
|
||||
</Field>
|
||||
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
||||
{t('pro.cab.timeoutHint', { min: c.acceptTimeoutMinutes })}
|
||||
</p>
|
||||
<Button disabled={save.isPending || gapN < c.minTravelGap} onClick={() => save.mutate({ companyId, autoAssign: auto, travelGapMinutes: gapN })}>
|
||||
{t('pro.cab.save')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.cab.managers')}</h2>
|
||||
<Card tight className="bc-stack" style={{ gap: 6 }}>
|
||||
{data.managers.map((m) => (
|
||||
<div key={m.id} className="bc-between" style={{ minHeight: 36 }}>
|
||||
<span>
|
||||
{m.name}
|
||||
{m.me && <span className="bc-muted"> · {t('pro.cab.you')}</span>}
|
||||
</span>
|
||||
{!m.me && (
|
||||
<button type="button" className="cab-link" onClick={async () => (await confirmDialog(t('pro.cab.removeManagerConfirm', { name: m.name }))) && remove.mutate({ companyId, managerId: m.id })}>
|
||||
{t('pro.cab.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
<p className="bc-small" style={{ margin: 0 }}>
|
||||
{t('pro.cab.managerInvite')}
|
||||
</p>
|
||||
<CopyBox url={c.managerLink} />
|
||||
</section>
|
||||
|
||||
{isWeb() && (
|
||||
<Button variant="ghost" onClick={() => logout.mutate()}>
|
||||
{t('pro.cab.logout')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -64,9 +64,11 @@ export function History() {
|
||||
<div className="bc-small bc-muted">
|
||||
{history.data.rate.kind === 'preset'
|
||||
? t('pro.history.ratePreset', { name: history.data.rate.name })
|
||||
: history.data.rate.type === 'PERCENT'
|
||||
? t('pro.history.ratePercent', { value: history.data.rate.value })
|
||||
: t('pro.history.rateFixed', { value: amd(history.data.rate.value) })}
|
||||
: history.data.rate.value == null
|
||||
? t('pro.history.ratePartner')
|
||||
: history.data.rate.type === 'PERCENT'
|
||||
? t('pro.history.ratePercent', { value: history.data.rate.value })
|
||||
: t('pro.history.rateFixed', { value: amd(history.data.rate.value) })}
|
||||
</div>
|
||||
{history.data.orders.length === 0 ? (
|
||||
<p className="bc-muted">{t('pro.history.empty')}</p>
|
||||
|
||||
@@ -81,7 +81,7 @@ export function Job({ id }: { id: string }) {
|
||||
</dd>
|
||||
<dt>{t('pro.job.price')}</dt>
|
||||
<dd>
|
||||
{amd(j.totalPrice)} · {t(`payment.${j.paymentStatus}`)}
|
||||
{amd(j.dueAmount)} · {t(`payment.${j.paymentStatus}`)}
|
||||
</dd>
|
||||
</dl>
|
||||
{paramsExtras(t, j.params).length > 0 && (
|
||||
@@ -141,7 +141,7 @@ export function Job({ id }: { id: string }) {
|
||||
</p>
|
||||
)}
|
||||
{j.paymentStatus === 'UNPAID' && j.status !== 'NEW' && (
|
||||
<Button variant="outline" block disabled={paid.isPending} onClick={async () => (await confirmDialog(t('pro.job.confirmPaid', { amount: amd(j.totalPrice) }))) && paid.mutate({ orderId: j.id })}>
|
||||
<Button variant="outline" block disabled={paid.isPending} onClick={async () => (await confirmDialog(t('pro.job.confirmPaid', { amount: amd(j.dueAmount) }))) && paid.mutate({ orderId: j.id })}>
|
||||
{t('pro.job.markPaid')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -2,12 +2,14 @@ import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
|
||||
// API_PROXY — другой локальный API (например, второй экземпляр на соседнем порту)
|
||||
const api = process.env.API_PROXY ?? 'http://localhost:4000';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: true,
|
||||
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
|
||||
proxy: { '/trpc': api, '/files': api, '/client-log': api },
|
||||
},
|
||||
build: { target: 'es2022', sourcemap: true },
|
||||
});
|
||||
+163
-4
@@ -2,10 +2,10 @@
|
||||
* Воркер: превращает доменные события в сообщения Telegram на языке получателя (MSG-2, MSG-3)
|
||||
* и раз в минуту запускает фоновые задачи — напоминания и истечение предложений.
|
||||
*/
|
||||
import { getFlow, type ServiceFlow } from '@barev/core';
|
||||
import { getFlow, toYerevan, type ServiceFlow } from '@barev/core';
|
||||
import { getDb } from '@barev/db';
|
||||
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
|
||||
import { autoAssign, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
|
||||
import { autoAssign, retryCompanyAutoAssign, activatePendingPoints, burnPoints, buildPointSuggestions, expirePointRequests, reconcilePoints, warnExpiringPoints, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
|
||||
import { getFile } from '@barev/services/storage';
|
||||
import { loadRootEnv } from '@barev/services/env';
|
||||
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
|
||||
@@ -119,6 +119,7 @@ async function loadOrder(id: string) {
|
||||
district: true,
|
||||
client: { include: { user: true } },
|
||||
provider: { include: { user: true } },
|
||||
company: { select: { autoAssign: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -166,7 +167,108 @@ async function dispatchers(companyId: string) {
|
||||
return db.companyDispatcher.findMany({ where: { companyId }, include: { user: true } });
|
||||
}
|
||||
|
||||
// ───── барики ─────
|
||||
|
||||
const pts = (locale: Locale, n: number) => formatAmd(locale, n).replace(/\s?֏$/, '');
|
||||
const pickName = (v: unknown, locale: Locale) => {
|
||||
const m = (v ?? {}) as Record<string, string>;
|
||||
return m[locale] || m.ru || m.en || '';
|
||||
};
|
||||
const pointsButton = (locale: Locale) => compactRows([[webAppButton(createT(locale)('notify.points.open'), `${CLIENT_APP}/?r=/points`)]]);
|
||||
async function toClientUser(clientId: string, build: (t: ReturnType<typeof createT>, locale: Locale, c: { pointsActive: number }) => string | null) {
|
||||
const c = await db.client.findUnique({ where: { id: clientId }, include: { user: true } });
|
||||
if (!c) return;
|
||||
const locale = c.user.locale as Locale;
|
||||
const text = build(createT(locale), locale, c);
|
||||
if (text) await clientBot.send({ chatId: c.user.telegramId, text, buttons: pointsButton(locale) });
|
||||
}
|
||||
const dateL = (locale: Locale, d: Date) => formatDateTime(locale, d, { day: 'numeric', month: 'long' });
|
||||
|
||||
async function handlePoints(event: DomainEvent): Promise<boolean> {
|
||||
switch (event.type) {
|
||||
case 'points.accrued':
|
||||
case 'points.activated': {
|
||||
const a = await db.pointsAccrual.findUnique({ where: { id: event.accrualId }, include: { category: true, bonusType: true } });
|
||||
if (!a) return true;
|
||||
await toClientUser(a.clientId, (t, l, c) => {
|
||||
const comment = a.commentForClient && a.bonusType?.code !== 'housewarming' ? `\n💬 ${escapeHtml(a.commentForClient)}` : '';
|
||||
if (event.type === 'points.activated') return t('notify.points.activated', { amount: pts(l, a.amount), balance: pts(l, c.pointsActive) });
|
||||
if (a.kind === 'BONUS') {
|
||||
const what = a.bonusType?.code === 'housewarming' && a.commentForClient ? t('notify.points.housewarming', { agency: a.commentForClient }) : pickName(a.bonusType?.name, l);
|
||||
return (a.expiresAt ? t('notify.points.bonus', { amount: pts(l, a.amount), what, until: dateL(l, a.expiresAt) }) : t('notify.points.bonusNoTtl', { amount: pts(l, a.amount), what })) + comment;
|
||||
}
|
||||
const what = pickName(a.category?.name, l);
|
||||
return (a.status === 'PENDING'
|
||||
? t('notify.points.pending', { amount: pts(l, a.amount), what, date: dateL(l, a.serviceDate), available: dateL(l, a.availableAt) })
|
||||
: t('notify.points.active', { amount: pts(l, a.amount), what, balance: pts(l, c.pointsActive) })) + comment;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'points.redeemed': {
|
||||
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId } });
|
||||
if (!r) return true;
|
||||
await toClientUser(r.clientId, (t, l, c) =>
|
||||
r.orderAmount != null
|
||||
? t('notify.points.redeemed', { amount: pts(l, r.amount), due: formatAmd(l, Math.max(0, r.orderAmount - r.amount)), balance: pts(l, c.pointsActive) })
|
||||
: t('notify.points.redeemedNoOrder', { amount: pts(l, r.amount), balance: pts(l, c.pointsActive) }),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'points.rejected': {
|
||||
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId } });
|
||||
if (!r) return true;
|
||||
await toClientUser(r.clientId, (t) => (r.reason === 'timeout' ? t('notify.points.requestExpired') : t('notify.points.rejected', { reason: escapeHtml(r.reason === 'declined' ? t('notify.points.declinedGeneric') : (r.reason ?? '')) })));
|
||||
return true;
|
||||
}
|
||||
case 'points.reversed': {
|
||||
const rv = await db.pointsReversal.findUnique({ where: { id: event.reversalId } });
|
||||
if (!rv) return true;
|
||||
const reasonText = (t: ReturnType<typeof createT>) => escapeHtml(rv.reason === 'order_cancelled' ? t('notify.points.orderCancelled') : rv.reason === 'order_edited' ? t('notify.points.orderEdited') : rv.reason);
|
||||
if (rv.targetType === 'REDEMPTION') {
|
||||
await toClientUser(rv.clientId, (t, l, c) => t('notify.points.returned', { amount: pts(l, rv.amount), balance: pts(l, c.pointsActive) }));
|
||||
} else {
|
||||
const a = await db.pointsAccrual.findUnique({ where: { id: rv.targetId }, include: { bonusType: true } });
|
||||
await toClientUser(rv.clientId, (t, l, c) => t('notify.points.reversed', { what: a?.orderRef ?? pickName(a?.bonusType?.name, l), reason: reasonText(t), balance: pts(l, c.pointsActive) }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'points.expiring':
|
||||
await toClientUser(event.clientId, (t, l) => t('notify.points.expiring', { days: event.days, amount: pts(l, event.amount) }));
|
||||
return true;
|
||||
case 'points.expired':
|
||||
await toClientUser(event.clientId, (t, l) => t('notify.points.expired', { amount: pts(l, event.amount) }));
|
||||
return true;
|
||||
case 'points.requested': {
|
||||
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId }, include: { client: { include: { user: true } }, order: true } });
|
||||
if (!r) return true;
|
||||
const order = r.order ? `\n${adminT('bot.admin.points.forOrder', { number: r.order.number, sum: formatAmd('ru', r.order.totalPrice) })}` : r.note ? `\n💬 ${escapeHtml(r.note)}` : '';
|
||||
const buttons: InlineButton[][] = [[...(r.order ? [{ text: adminT('bot.admin.points.confirm'), callback_data: `pr:ok:${r.id}` }] : []), { text: adminT('bot.admin.points.reject'), callback_data: `pr:no:${r.id}` }]];
|
||||
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=requests` }]] : [];
|
||||
await toAdmins(`🪙 <b>${adminT('bot.admin.points.request', { amount: pts('ru', r.amount) })}</b>\n${who(r.client.user)} · ${adminT('bot.admin.points.balance', { balance: pts('ru', r.client.pointsActive) })}${order}`, compactRows([...buttons, ...link]));
|
||||
return true;
|
||||
}
|
||||
case 'points.suggestions': {
|
||||
const open = await db.pointsSuggestion.count({ where: { status: 'OPEN' } });
|
||||
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=suggestions` }]] : [];
|
||||
await toAdmins(`💡 ${adminT('bot.admin.points.suggestions', { count: event.count, open })}`, compactRows(link));
|
||||
return true;
|
||||
}
|
||||
case 'points.overLimit': {
|
||||
const s = await db.pointsSuggestion.findUnique({ where: { id: event.suggestionId }, include: { client: { include: { user: true } } } });
|
||||
if (!s) return true;
|
||||
const by = typeof (s.payload as Record<string, unknown>).adminId === 'string' ? await db.adminUser.findUnique({ where: { id: (s.payload as Record<string, string>).adminId } }) : null;
|
||||
const owners = await db.adminUser.findMany({ where: { active: true, telegramId: { not: null }, roles: { has: 'SUPERADMIN' } } });
|
||||
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=suggestions` }]] : [];
|
||||
for (const o of owners) await proBot.send({ chatId: o.telegramId!, text: `⚠️ ${adminT('bot.admin.points.overLimit', { by: escapeHtml(by?.name ?? '—'), amount: pts('ru', s.suggestedAmount) })}\n${who(s.client.user)}`, buttons: compactRows(link) });
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(event: DomainEvent) {
|
||||
if (event.type.startsWith('points.') && (await handlePoints(event))) return;
|
||||
switch (event.type) {
|
||||
case 'order.created': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
@@ -207,6 +309,14 @@ async function handle(event: DomainEvent) {
|
||||
if (o) await toClient(o, `notify.event.${event.code}`);
|
||||
return;
|
||||
}
|
||||
case 'order.edited': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
if (!o) return;
|
||||
const locale = o.client.user.locale as Locale;
|
||||
await toClient(o, 'notify.orderEdited', { total: formatAmd(locale, o.totalPrice - o.pointsUsed) });
|
||||
if (o.provider?.user) await toProvider(o, o.provider.user, 'notify.pro.orderEdited');
|
||||
return;
|
||||
}
|
||||
case 'order.rescheduled': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
if (!o) return;
|
||||
@@ -261,9 +371,28 @@ async function handle(event: DomainEvent) {
|
||||
await reportAuto(o.id, await autoAssign(ctx, o.id));
|
||||
return;
|
||||
}
|
||||
// при автораспределении партнёра заказ сам уйдёт следующему; менеджеру пишем, если сотрудников не осталось (company.noStaff)
|
||||
if (o.company?.autoAssign) return;
|
||||
for (const d of await dispatchers(companyId)) await toProvider(o, d.user, 'notify.dispatcher.returned');
|
||||
return;
|
||||
}
|
||||
case 'company.noStaff': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
if (!o) return;
|
||||
for (const d of await dispatchers(event.companyId)) await toProvider(o, d.user, 'notify.dispatcher.noStaff', proDetails(o, d.user.locale as Locale));
|
||||
return;
|
||||
}
|
||||
case 'partner.returned': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
const company = await db.providerCompany.findUnique({ where: { id: event.companyId } });
|
||||
if (!o || !company) return;
|
||||
await toAdmins(
|
||||
`↩️ ${escapeHtml(adminT(event.reason === 'declined' ? 'bot.admin.partnerDeclined' : 'bot.admin.partnerExpired', { number: o.number, partner: company.name }))}\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}`,
|
||||
compactRows([[orderButton(o.id)]]),
|
||||
);
|
||||
if (event.reason === 'expired') for (const d of await dispatchers(event.companyId)) await toProvider(o, d.user, 'notify.dispatcher.expired');
|
||||
return;
|
||||
}
|
||||
case 'company.order': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
if (!o) return;
|
||||
@@ -413,9 +542,10 @@ async function handle(event: DomainEvent) {
|
||||
case 'webRequest.created': {
|
||||
const r = await db.webRequest.findUnique({ where: { id: event.requestId }, include: { service: true, district: true } });
|
||||
if (!r) return;
|
||||
const date = formatDateTime('ru', r.preferredAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
const date = r.preferredAt ? formatDateTime('ru', r.preferredAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }) : adminT('bot.admin.noTime');
|
||||
const place = r.district ? adminT(`districts.${r.district.code}`) : adminT('bot.admin.noAddress');
|
||||
await toAdmins(
|
||||
`🧹 <b>${adminT('bot.admin.newWebRequest', { number: r.number })}</b>\n${escapeHtml(adminT(`services.${r.service.code}.name`))} · ${date}\n${escapeHtml(r.name ?? '')} ${escapeHtml(r.phone)} · ${escapeHtml(adminT(`districts.${r.district.code}`))} · ${formatAmd('ru', r.totalPrice)}`,
|
||||
`🧹 <b>${adminT('bot.admin.newWebRequest', { number: r.number })}</b>\n${escapeHtml(adminT(`services.${r.service.code}.name`))} · ${date}\n${escapeHtml(r.name ?? '')} ${escapeHtml(r.phone)} · ${escapeHtml(place)} · ${formatAmd('ru', r.totalPrice)}`,
|
||||
compactRows([[adminButton('/requests')]]),
|
||||
);
|
||||
return;
|
||||
@@ -448,6 +578,9 @@ async function tick() {
|
||||
if (reminders || expired) console.log(`[worker] reminders=${reminders} expiredOffers=${expired}`);
|
||||
// заказы, которые ещё ждут исполнителя, — пробуем распределить снова
|
||||
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
|
||||
// заказы партнёров с автораспределением, которые ждут сотрудника
|
||||
await retryCompanyAutoAssign(ctx);
|
||||
await pointsJobs();
|
||||
// задачи продажников, у которых наступил срок
|
||||
await dueSalesTasks(ctx);
|
||||
// рассылки, прерванные перезапуском, — продолжить
|
||||
@@ -458,6 +591,32 @@ async function tick() {
|
||||
ticking = false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Барики: ожидающие → доступные и просроченные запросы — каждую минуту;
|
||||
* раз в час — предложения системы; раз в сутки — сгорание (03:00), сверка (04:00), предупреждения (11:00).
|
||||
* Задачи идемпотентны: повторный запуск после рестарта ничего не задваивает.
|
||||
*/
|
||||
const lastRun = new Map<string, string>();
|
||||
async function once(key: string, period: string, fn: () => Promise<unknown>) {
|
||||
if (lastRun.get(key) === period) return;
|
||||
lastRun.set(key, period);
|
||||
await fn();
|
||||
}
|
||||
async function pointsJobs() {
|
||||
await activatePendingPoints(ctx);
|
||||
await expirePointRequests(ctx);
|
||||
const y = toYerevan(new Date());
|
||||
const day = `${y.year}-${y.month}-${y.day}`;
|
||||
await once('suggestions', `${day} ${y.hour}`, () => buildPointSuggestions(ctx));
|
||||
if (y.hour >= 3) await once('burn', day, () => burnPoints(ctx));
|
||||
if (y.hour >= 4)
|
||||
await once('reconcile', day, async () => {
|
||||
const diff = await reconcilePoints(db);
|
||||
if (diff.length) await toAdmins(`⚠️ ${adminT('bot.admin.points.mismatch', { count: diff.length })}`);
|
||||
});
|
||||
if (y.hour >= 11) await once('warn', day, () => warnExpiringPoints(ctx));
|
||||
}
|
||||
|
||||
setInterval(tick, 60_000);
|
||||
void tick();
|
||||
|
||||
|
||||
Reference in new issue
Block a user