Backoffice stage 4: partners (agencies, venues) and sales CRM

- CRM: configurable pipeline (default: New → Contact → Meeting → Negotiation →
  Connected / Lost), drag-and-drop board, lead card with call/meeting/note history,
  photos and files, tasks with Telegram reminders at the due time, map of venues
  (OpenStreetMap via Leaflet), lost reason. Sales reps see only their own leads.
- "Connected" in one click: a venue becomes a club card partner with a one-time
  owner link to the work bot; an agency gets a code and a client link. The sales rep
  is recorded on the partner for the bonus.
- Sales bonus formula in settings (per venue, per agency, % of revenue from the
  rep's agency clients for N months); defaults are 0 until the owner sets them.
- Agencies (superadmin): clients, gift subscriptions, reward by month, payouts,
  client link, edit. Venues: offers by membership level (5 languages), staff,
  card checks, 8-week activity, owner link, map point.
- Schema: SalesStage/SalesLead/SalesActivity/SalesTask; salesId on Agency and
  Partner; lat/lng/phone/note on Partner; agencyId on Payout.
- Tests for stage rules, conversion, task reminders and bonus; dev:crm-demo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 03:02:03 +04:00
1 parent a848bb13e2
commit ac786cc5cc
30 files changed
+4311 -11

No files matched your search

+2 -1
View File
@@ -15,7 +15,8 @@
"dev:initdata": "tsx scripts/dev-initdata.ts",
"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:chats-demo": "tsx scripts/dev-chats-demo.ts",
"dev:crm-demo": "tsx scripts/dev-crm-demo.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1152.0",
+92
View File
@@ -0,0 +1,92 @@
/**
* Локальная разработка: демо для CRM продажника и раздела «Партнёры» (этап 4).
* pnpm --filter @barev/api dev:crm-demo
* Создаёт продажника «Давид Саргсян», лиды по этапам с точками на карте Еревана, историю и задачи.
* Повторный запуск пересоздаёт демо-лиды (по имени продажника).
*/
import { getDb } from '@barev/db';
import { env } from '../src/env.ts';
if (env.isProd) throw new Error('Только для разработки');
const db = getDb();
const H = 3600_000;
const now = Date.now();
const ago = (h: number) => new Date(now - h * H);
const sales =
(await db.adminUser.findFirst({ where: { name: 'Давид Саргсян' } })) ?? (await db.adminUser.create({ data: { name: 'Давид Саргсян', roles: ['SALES'] } }));
const owner = await db.adminUser.findFirst({ where: { roles: { has: 'SUPERADMIN' } } });
await db.salesLead.deleteMany({ where: { ownerId: sales.id, partnerId: null, agencyId: null } });
await db.salesTask.deleteMany({ where: { assigneeId: sales.id } });
const stages = Object.fromEntries((await db.salesStage.findMany()).map((s) => [s.id, s]));
const S = { new: 'stg_new', contact: 'stg_contact', meeting: 'stg_meeting', talks: 'stg_talks', lost: 'stg_lost' } as const;
if (!stages[S.new]) throw new Error('Нет этапов воронки — примените миграции');
type Act = { type: 'CALL' | 'MEETING' | 'NOTE' | 'STAGE'; text: string; h: number };
async function lead(
data: { kind: 'VENUE' | 'AGENCY'; name: string; category?: string; contactName?: string; phone?: string; telegram?: string; address?: string; lat?: number; lng?: number; note?: string; lostReason?: string },
stageId: string,
stageH: number,
acts: Act[] = [],
tasks: { title: string; inH: number }[] = [],
) {
const l = await db.salesLead.create({ data: { ...data, stageId, stageChangedAt: ago(stageH), ownerId: sales.id, createdAt: ago(Math.max(stageH, ...acts.map((a) => a.h)) + 2) } });
for (const a of acts) await db.salesActivity.create({ data: { leadId: l.id, authorId: sales.id, type: a.type, text: a.text, createdAt: ago(a.h) } });
for (const t of tasks) await db.salesTask.create({ data: { leadId: l.id, assigneeId: sales.id, title: t.title, dueAt: new Date(now + t.inH * H), createdById: sales.id } });
return l;
}
await lead({ kind: 'VENUE', name: 'Gemini Coffee', category: 'cafe', address: 'Абовяна 12', lat: 40.1835, lng: 44.5181, phone: '+374 91 222 333' }, S.new, 20);
await lead({ kind: 'VENUE', name: 'Tumanyan Shaurma', category: 'restaurant', address: 'Туманяна 40', lat: 40.1868, lng: 44.5108 }, S.new, 6, [], [{ title: 'Позвонить', inH: 3 }]);
await lead(
{ kind: 'VENUE', name: 'Ankyun Wine Bar', category: 'restaurant', contactName: 'Ани, управляющая', phone: '+374 93 555 010', telegram: '@ankyun_bar', address: 'Сарьяна 8', lat: 40.1822, lng: 44.5064 },
S.contact,
50,
[
{ type: 'CALL', text: 'Интересно, просят условия письменно. Собственник решает по средам.', h: 50 },
{ type: 'STAGE', text: 'Контакт', h: 50 },
],
[{ title: 'Отправить условия', inH: -5 }],
);
await lead(
{ kind: 'VENUE', name: 'Lavash Restaurant', category: 'restaurant', contactName: 'Армен, собственник', phone: '+374 99 100 200', address: 'Туманяна 21', lat: 40.1859, lng: 44.5137 },
S.meeting,
30,
[
{ type: 'CALL', text: 'Договорились о встрече в четверг 15:00', h: 80 },
{ type: 'MEETING', text: 'Готовы дать −10% по карте и десерт в подарок для Elite. Нужен макет наклейки на дверь.', h: 30 },
],
[{ title: 'Привезти наклейку и согласовать предложения', inH: 26 }],
);
await lead(
{ kind: 'VENUE', name: 'Salon Nuar', category: 'beauty', contactName: 'Нуне', phone: '+374 77 300 400', address: 'Пушкина 33', lat: 40.1801, lng: 44.5089 },
S.talks,
12,
[{ type: 'MEETING', text: 'Хотят −15% на первое посещение, обсуждают с партнёром.', h: 12 }],
[{ title: 'Узнать решение', inH: 48 }],
);
await lead({ kind: 'AGENCY', name: 'Yerevan Homes', contactName: 'Лусине, директор', phone: '+374 98 777 888', telegram: '@yerevanhomes' }, S.contact, 26, [{ type: 'CALL', text: 'Каждый месяц ~15 клиентов из РФ. Интересует подарочный месяц для клиентов.', h: 26 }], [{ title: 'Встреча в офисе', inH: 20 }]);
await lead({ kind: 'AGENCY', name: 'Relocate.am', contactName: 'Гор', telegram: '@relocate_am' }, S.talks, 70, [{ type: 'MEETING', text: 'Готовы, ждут ставку 12% вместо 10%.', h: 70 }]);
await lead({ kind: 'VENUE', name: 'Kond Bakery', category: 'cafe', address: 'Конд', lat: 40.1773, lng: 44.4991, lostReason: 'Не устроили условия' }, S.lost, 100, [{ type: 'STAGE', text: 'Отказ: Не устроили условия', h: 100 }]);
// подключённые: точка на карте у существующих партнёров, продажник — у части
const partners = await db.partner.findMany({ orderBy: { createdAt: 'asc' } });
const spots: [number, number][] = [
[40.1811, 44.5136],
[40.1776, 44.5126],
[40.1893, 44.5153],
];
for (const [i, p] of partners.entries()) {
const s = spots[i % spots.length]!;
await db.partner.update({ where: { id: p.id }, data: { lat: s[0] + i * 0.0012, lng: s[1] - i * 0.0009, salesId: i === 0 ? sales.id : (p.salesId ?? owner?.id ?? null) } });
}
const agency = await db.agency.findFirst({ orderBy: { createdAt: 'asc' } });
if (agency) await db.agency.update({ where: { id: agency.id }, data: { salesId: sales.id } });
// задача без лида
await db.salesTask.create({ data: { assigneeId: sales.id, title: 'Обойти Каскад: кафе без партнёрства', dueAt: new Date(now + 30 * H), createdById: sales.id } });
console.log(`CRM демо готово: продажник ${sales.name}, лидов 8, партнёров на карте ${partners.length}.`);
process.exit(0);
+257
View File
@@ -0,0 +1,257 @@
import { convertLead, moveLead, salesBonus, getSettings } from '@barev/services';
import type { Prisma } from '@barev/db';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/** CRM ведут продажники; суперадмин видит всех и настраивает воронку */
const salesProcedure = rolesProcedure('SALES');
const CATEGORIES = ['cafe', 'restaurant', 'shop', 'beauty', 'other'] as const;
/** Продажник видит только свои лиды */
function scope(ctx: { isSuper: boolean; admin: { id: string } }, ownerId?: string): Prisma.SalesLeadWhereInput {
if (!ctx.isSuper) return { ownerId: ctx.admin.id };
return ownerId ? { ownerId: ownerId === 'none' ? null : ownerId } : {};
}
async function ownLead(ctx: { db: import('@barev/db').Db; isSuper: boolean; admin: { id: string } }, id: string) {
const lead = await ctx.db.salesLead.findUnique({ where: { id } });
if (!lead || (!ctx.isSuper && lead.ownerId !== ctx.admin.id)) throw new TRPCError({ code: 'NOT_FOUND' });
return lead;
}
const leadInput = z.object({
kind: z.enum(['VENUE', 'AGENCY']),
name: z.string().trim().min(1).max(120),
category: z.enum(CATEGORIES).nullable().optional(),
contactName: z.string().trim().max(120).nullable().optional(),
phone: z.string().trim().max(40).nullable().optional(),
telegram: z.string().trim().max(80).nullable().optional(),
address: z.string().trim().max(200).nullable().optional(),
lat: z.number().min(-90).max(90).nullable().optional(),
lng: z.number().min(-180).max(180).nullable().optional(),
note: z.string().max(4000).nullable().optional(),
ownerId: z.string().nullable().optional(),
});
const monthRange = (key: string) => {
const [y, m] = key.split('-').map(Number);
return { from: new Date(Date.UTC(y!, m! - 1, 1) - 4 * 3600_000), to: new Date(Date.UTC(y!, m!, 1) - 4 * 3600_000) };
};
export const boCrmRouter = router({
stages: salesProcedure.query(({ ctx }) => ctx.db.salesStage.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' } })),
/** Воронка: этапы целиком (порядок, названия, цвета); этап с лидами не удаляется, а скрывается */
saveStages: superProcedure
.input(z.array(z.object({ id: z.string().optional(), name: z.string().trim().min(1).max(40), kind: z.enum(['OPEN', 'WON', 'LOST']), color: z.string().max(12) })).min(2).max(12))
.mutation(async ({ ctx, input }) => {
if (!input.some((s) => s.kind === 'WON') || !input.some((s) => s.kind === 'OPEN')) throw new TRPCError({ code: 'BAD_REQUEST', message: 'STAGES_INVALID' });
const keep = new Set(input.map((s) => s.id).filter(Boolean));
const all = await ctx.db.salesStage.findMany({ include: { _count: { select: { leads: true } } } });
for (const s of all) {
if (keep.has(s.id)) continue;
if (s._count.leads) await ctx.db.salesStage.update({ where: { id: s.id }, data: { active: false } });
else await ctx.db.salesStage.delete({ where: { id: s.id } });
}
for (const [i, s] of input.entries()) {
if (s.id) await ctx.db.salesStage.update({ where: { id: s.id }, data: { name: s.name, kind: s.kind, color: s.color, sortOrder: i, active: true } });
else await ctx.db.salesStage.create({ data: { name: s.name, kind: s.kind, color: s.color, sortOrder: i } });
}
}),
/** Доска: лиды по этапам */
board: salesProcedure
.input(z.object({ kind: z.enum(['VENUE', 'AGENCY']).optional(), ownerId: z.string().optional(), search: z.string().trim().max(100).optional() }))
.query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const leads = await ctx.db.salesLead.findMany({
where: {
...scope(ctx, input.ownerId),
...(input.kind ? { kind: input.kind } : {}),
...(input.search ? { OR: [{ name: { contains: input.search, mode: 'insensitive' } }, { contactName: { contains: input.search, mode: 'insensitive' } }, { phone: { contains: input.search } }, { address: { contains: input.search, mode: 'insensitive' } }] } : {}),
},
include: {
owner: { select: { id: true, name: true } },
tasks: { where: { doneAt: null }, orderBy: { dueAt: 'asc' }, take: 1 },
activities: { orderBy: { createdAt: 'desc' }, take: 1, select: { createdAt: true, type: true } },
},
orderBy: { stageChangedAt: 'desc' },
take: 500,
});
return leads.map((l) => ({
id: l.id,
kind: l.kind,
name: l.name,
category: l.category,
contactName: l.contactName,
phone: l.phone,
address: l.address,
stageId: l.stageId,
stageChangedAt: l.stageChangedAt,
owner: l.owner,
converted: !!(l.partnerId || l.agencyId),
nextTask: l.tasks[0] ? { title: l.tasks[0].title, dueAt: l.tasks[0].dueAt, overdue: l.tasks[0].dueAt < now } : null,
lastTouch: l.activities[0]?.createdAt ?? l.createdAt,
}));
}),
get: salesProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
await ownLead(ctx, input.id);
const l = await ctx.db.salesLead.findUniqueOrThrow({
where: { id: input.id },
include: {
stage: true,
owner: { select: { id: true, name: true } },
partner: { select: { id: true, name: true, ownerInviteCode: true, _count: { select: { members: true } } } },
agency: { select: { id: true, name: true, code: true, _count: { select: { clients: true } } } },
activities: { orderBy: { createdAt: 'desc' }, take: 200 },
tasks: { orderBy: [{ doneAt: 'asc' }, { dueAt: 'asc' }], include: { assignee: { select: { name: true } } } },
},
});
const authorIds = [...new Set(l.activities.map((a) => a.authorId).filter((x): x is string => !!x))];
const authors = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: authorIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
return {
...l,
activities: l.activities.map((a) => ({ id: a.id, type: a.type, text: a.text, file: a.fileKey ? { name: a.fileName, mime: a.mime, size: a.size } : null, author: a.authorId ? (authors.get(a.authorId) ?? null) : null, at: a.createdAt })),
tasks: l.tasks.map((t) => ({ id: t.id, title: t.title, dueAt: t.dueAt, doneAt: t.doneAt, assignee: t.assignee.name })),
partner: l.partner
? { id: l.partner.id, name: l.partner.name, ownerLinked: l.partner._count.members > 0, ownerInviteUrl: l.partner.ownerInviteCode ? `https://t.me/${process.env.PRO_BOT_USERNAME ?? 'barev_pro_bot'}?start=po-${l.partner.ownerInviteCode}` : null }
: null,
agency: l.agency ? { id: l.agency.id, name: l.agency.name, code: l.agency.code, clients: l.agency._count.clients } : null,
};
}),
create: salesProcedure.input(leadInput).mutation(async ({ ctx, input }) => {
const first = await ctx.db.salesStage.findFirst({ where: { active: true, kind: 'OPEN' }, orderBy: { sortOrder: 'asc' } });
if (!first) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'STAGES_INVALID' });
const { ownerId, ...data } = input;
const lead = await ctx.db.salesLead.create({ data: { ...data, stageId: first.id, ownerId: ctx.isSuper ? (ownerId === undefined ? ctx.admin.id : ownerId) : ctx.admin.id } });
return { id: lead.id };
}),
update: salesProcedure.input(leadInput.partial().extend({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ownLead(ctx, input.id);
const { id, ownerId, kind: _kind, ...data } = input;
await ctx.db.salesLead.update({ where: { id }, data: { ...data, ...(ctx.isSuper && ownerId !== undefined ? { ownerId } : {}) } });
}),
remove: salesProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const l = await ownLead(ctx, input.id);
if (l.partnerId || l.agencyId) throw new TRPCError({ code: 'BAD_REQUEST', message: 'ALREADY_CONVERTED' });
await ctx.db.salesLead.delete({ where: { id: input.id } });
}),
move: salesProcedure.input(z.object({ id: z.string(), stageId: z.string(), lostReason: z.string().max(500).optional() })).mutation(async ({ ctx, input }) => {
await ownLead(ctx, input.id);
await moveLead(ctx.svc, { leadId: input.id, stageId: input.stageId, byAdminId: ctx.admin.id, lostReason: input.lostReason });
}),
/** «Подключён»: создаём партнёра или агентство и отдаём ссылку */
convert: salesProcedure
.input(z.object({ id: z.string(), agencyCode: z.string().max(32).optional(), commissionPct: z.number().int().min(0).max(100).optional() }))
.mutation(async ({ ctx, input }) => {
await ownLead(ctx, input.id);
return convertLead(ctx.svc, { leadId: input.id, byAdminId: ctx.admin.id, agencyCode: input.agencyCode, commissionPct: ctx.isSuper ? input.commissionPct : undefined });
}),
/** Звонок, встреча, заметка */
addActivity: salesProcedure
.input(z.object({ id: z.string(), type: z.enum(['CALL', 'MEETING', 'NOTE']), text: z.string().trim().min(1).max(4000) }))
.mutation(async ({ ctx, input }) => {
await ownLead(ctx, input.id);
await ctx.db.salesActivity.create({ data: { leadId: input.id, authorId: ctx.admin.id, type: input.type, text: input.text } });
}),
// ───── задачи ─────
tasks: salesProcedure.input(z.object({ assigneeId: z.string().optional(), done: z.boolean().default(false) }).optional()).query(async ({ ctx, input }) => {
const assigneeId = ctx.isSuper ? input?.assigneeId : ctx.admin.id;
const rows = await ctx.db.salesTask.findMany({
where: { ...(assigneeId ? { assigneeId } : ctx.isSuper ? {} : { assigneeId: ctx.admin.id }), doneAt: input?.done ? { not: null } : null },
orderBy: input?.done ? { doneAt: 'desc' } : { dueAt: 'asc' },
take: 200,
include: { lead: { select: { id: true, name: true, phone: true } }, assignee: { select: { id: true, name: true } } },
});
return rows.map((t) => ({ id: t.id, title: t.title, dueAt: t.dueAt, doneAt: t.doneAt, lead: t.lead, assignee: t.assignee }));
}),
addTask: salesProcedure
.input(z.object({ leadId: z.string().optional(), title: z.string().trim().min(1).max(200), dueAt: z.date(), assigneeId: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
if (input.leadId) await ownLead(ctx, input.leadId);
await ctx.db.salesTask.create({ data: { leadId: input.leadId, title: input.title, dueAt: input.dueAt, assigneeId: ctx.isSuper && input.assigneeId ? input.assigneeId : ctx.admin.id, createdById: ctx.admin.id } });
}),
setTaskDone: salesProcedure.input(z.object({ id: z.string(), done: z.boolean() })).mutation(async ({ ctx, input }) => {
const t = await ctx.db.salesTask.findUnique({ where: { id: input.id } });
if (!t || (!ctx.isSuper && t.assigneeId !== ctx.admin.id)) throw new TRPCError({ code: 'NOT_FOUND' });
await ctx.db.salesTask.update({ where: { id: t.id }, data: { doneAt: input.done ? ctx.svc.now() : null } });
}),
removeTask: salesProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const t = await ctx.db.salesTask.findUnique({ where: { id: input.id } });
if (!t || (!ctx.isSuper && t.assigneeId !== ctx.admin.id)) throw new TRPCError({ code: 'NOT_FOUND' });
await ctx.db.salesTask.delete({ where: { id: t.id } });
}),
// ───── карта, команда, бонус ─────
/** Точки на карте: свои лиды-заведения и уже подключённые партнёры */
map: salesProcedure.query(async ({ ctx }) => {
const [leads, partners] = await Promise.all([
ctx.db.salesLead.findMany({ where: { ...scope(ctx), kind: 'VENUE', lat: { not: null }, lng: { not: null }, partnerId: null }, include: { stage: true } }),
ctx.db.partner.findMany({ where: { lat: { not: null }, lng: { not: null } }, select: { id: true, name: true, category: true, lat: true, lng: true, active: true, address: true } }),
]);
return {
leads: leads.map((l) => ({ id: l.id, name: l.name, lat: l.lat!, lng: l.lng!, stage: l.stage.name, color: l.stage.color, lost: l.stage.kind === 'LOST', address: l.address })),
partners: partners.map((p) => ({ id: p.id, name: p.name, lat: p.lat!, lng: p.lng!, active: p.active, address: p.address })),
};
}),
/** Продажники — для фильтра и передачи лида (видит суперадмин) */
team: salesProcedure.query(async ({ ctx }) => {
if (!ctx.isSuper) return [{ id: ctx.admin.id, name: ctx.admin.name }];
return ctx.db.adminUser.findMany({ where: { active: true, roles: { hasSome: ['SALES', 'SUPERADMIN'] } }, orderBy: { name: 'asc' }, select: { id: true, name: true } });
}),
/** Бонус за месяц: свой (продажник) или любого (суперадмин) */
bonus: salesProcedure.input(z.object({ month: z.string().regex(/^\d{4}-\d{2}$/), salesId: z.string().optional() })).query(async ({ ctx, input }) => {
const { from, to } = monthRange(input.month);
const who = ctx.isSuper && input.salesId ? input.salesId : ctx.admin.id;
return salesBonus(ctx.svc, who, from, to);
}),
/** Сводка воронки по этапам и итоги месяца — для шапки CRM */
summary: salesProcedure.input(z.object({ ownerId: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
const where = scope(ctx, input?.ownerId);
const now = ctx.svc.now();
const [byStage, overdue, s] = await Promise.all([
ctx.db.salesLead.groupBy({ by: ['stageId'], where, _count: true }),
ctx.db.salesTask.count({ where: { doneAt: null, dueAt: { lt: now }, ...(ctx.isSuper ? {} : { assigneeId: ctx.admin.id }) } }),
getSettings(ctx.db),
]);
return {
byStage: Object.fromEntries(byStage.map((b) => [b.stageId, b._count])),
overdue,
bonusConfigured: s['sales.bonusPerVenue'] + s['sales.bonusPerAgency'] + s['sales.bonusAgencyPct'] > 0,
};
}),
/** Формула бонуса (меняет суперадмин) */
bonusRules: salesProcedure.query(async ({ ctx }) => {
const s = await getSettings(ctx.db);
return { perVenue: s['sales.bonusPerVenue'], perAgency: s['sales.bonusPerAgency'], pct: s['sales.bonusAgencyPct'], months: s['sales.bonusAgencyMonths'] };
}),
setBonusRules: superProcedure
.input(z.object({ perVenue: z.number().int().min(0), perAgency: z.number().int().min(0), pct: z.number().int().min(0).max(100), months: z.number().int().min(1).max(60) }))
.mutation(async ({ ctx, input }) => {
const pairs: [string, number][] = [
['sales.bonusPerVenue', input.perVenue],
['sales.bonusPerAgency', input.perAgency],
['sales.bonusAgencyPct', input.pct],
['sales.bonusAgencyMonths', input.months],
];
for (const [key, value] of pairs) await ctx.db.setting.upsert({ where: { key }, create: { key, value }, update: { value } });
}),
});
+4
View File
@@ -5,6 +5,8 @@ import { boDashboardRouter } from './dashboard.ts';
import { boChatsRouter } from './chats.ts';
import { boOrdersRouter } from './orders.ts';
import { boProvidersRouter } from './providers.ts';
import { boCrmRouter } from './crm.ts';
import { boPartnersRouter } from './partners.ts';
/** API новой админки (apps/backoffice) */
export const backofficeRouter = router({
@@ -16,4 +18,6 @@ export const backofficeRouter = router({
chats: boChatsRouter,
orders: boOrdersRouter,
providers: boProvidersRouter,
crm: boCrmRouter,
partners: boPartnersRouter,
});
+228
View File
@@ -0,0 +1,228 @@
import { DAY, normalizeAgencyCode } from '@barev/core';
import { agencyLink, createOwnerInvite, savePartnerOffer } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { router, superProcedure } from '../../trpc.ts';
/** Партнёры (агентства и заведения) — деньги и условия, поэтому только суперадмин */
const p = superProcedure;
const localized = z.object({ ru: z.string().max(300), en: z.string().max(300), zh: z.string().max(300), hy: z.string().max(300), es: z.string().max(300) });
const CATEGORIES = ['cafe', 'restaurant', 'shop', 'beauty', 'other'] as const;
const proBotUrl = (payload: string) => `https://t.me/${process.env.PRO_BOT_USERNAME ?? 'barev_pro_bot'}?start=${payload}`;
const name = (u: { firstName: string | null; lastName: string | null; username: string | null }) => [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
const monthStart = (d: Date) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
const monthKey = (d: Date) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 7);
export const boPartnersRouter = router({
// ───── агентства ─────
agencies: p.query(async ({ ctx }) => {
const now = ctx.svc.now();
const agencies = await ctx.db.agency.findMany({
orderBy: [{ active: 'desc' }, { name: 'asc' }],
include: { sales: { select: { id: true, name: true } }, _count: { select: { clients: true } } },
});
const ids = agencies.map((a) => a.id);
const [gifts, accrued, accruedMonth, paid] = await Promise.all([
ctx.db.subscription.groupBy({ by: ['agencyId'], where: { agencyId: { in: ids }, source: 'AGENCY_GIFT' }, _count: true }),
ctx.db.agencyCommission.groupBy({ by: ['agencyId'], where: { agencyId: { in: ids } }, _sum: { amount: true } }),
ctx.db.agencyCommission.groupBy({ by: ['agencyId'], where: { agencyId: { in: ids }, period: monthKey(now) }, _sum: { amount: true } }),
ctx.db.payout.groupBy({ by: ['agencyId'], where: { agencyId: { in: ids }, status: 'PAID' }, _sum: { amount: true } }),
]);
const m = <T extends { agencyId: string | null }>(rows: T[], f: (r: T) => number) => new Map(rows.map((r) => [r.agencyId, f(r)]));
const g = m(gifts, (r) => r._count);
const acc = m(accrued, (r) => r._sum.amount ?? 0);
const accM = m(accruedMonth, (r) => r._sum.amount ?? 0);
const pd = m(paid, (r) => r._sum.amount ?? 0);
return agencies.map((a) => ({
id: a.id,
code: a.code,
name: a.name,
commissionPct: a.commissionPct,
active: a.active,
sales: a.sales,
clients: a._count.clients,
gifts: g.get(a.id) ?? 0,
thisMonth: accM.get(a.id) ?? 0,
accrued: acc.get(a.id) ?? 0,
balance: (acc.get(a.id) ?? 0) - (pd.get(a.id) ?? 0),
link: agencyLink(a.code),
}));
}),
agency: p.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const a = await ctx.db.agency.findUnique({ where: { id: input.id }, include: { sales: { select: { id: true, name: true } } } });
if (!a) throw new TRPCError({ code: 'NOT_FOUND' });
const [clients, commissions, payouts, gifts] = await Promise.all([
ctx.db.client.findMany({
where: { agencyId: a.id },
orderBy: { agencyAttachedAt: 'desc' },
take: 100,
include: { user: { select: { firstName: true, lastName: true, username: true } }, subscriptions: { where: { status: 'ACTIVE' }, select: { plan: true, startsAt: true, endsAt: true, source: true } }, _count: { select: { orders: true } } },
}),
ctx.db.agencyCommission.groupBy({ by: ['period'], where: { agencyId: a.id }, _sum: { amount: true }, _count: true, orderBy: { period: 'desc' }, take: 12 }),
ctx.db.payout.findMany({ where: { agencyId: a.id }, orderBy: { createdAt: 'desc' } }),
ctx.db.subscription.count({ where: { agencyId: a.id, source: 'AGENCY_GIFT' } }),
]);
const now = ctx.svc.now();
const accrued = commissions.reduce((s, c) => s + (c._sum.amount ?? 0), 0);
const paid = payouts.filter((x) => x.status === 'PAID').reduce((s, x) => s + x.amount, 0);
return {
id: a.id,
code: a.code,
name: a.name,
commissionPct: a.commissionPct,
contact: a.contact,
note: a.note,
active: a.active,
sales: a.sales,
createdAt: a.createdAt,
link: agencyLink(a.code),
gifts,
clients: clients.map((c) => {
const sub = c.subscriptions.find((s) => s.startsAt <= now && s.endsAt > now);
return { id: c.id, name: name(c.user), attachedAt: c.agencyAttachedAt, plan: sub?.plan ?? null, orders: c._count.orders };
}),
months: commissions.map((c) => ({ period: c.period, amount: c._sum.amount ?? 0, orders: c._count })),
accrued,
paid,
balance: accrued - paid,
payouts: payouts.map((x) => ({ id: x.id, amount: x.amount, paidAt: x.paidAt, note: x.note })),
};
}),
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() }))
.mutation(async ({ ctx, input }) => {
const code = normalizeAgencyCode(input.code);
if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' });
const clash = await ctx.db.agency.findUnique({ where: { code } });
if (clash && clash.id !== input.id) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_TAKEN' });
const { id, ...data } = input;
const a = id ? await ctx.db.agency.update({ where: { id }, data: { ...data, code } }) : await ctx.db.agency.create({ data: { ...data, code } });
return { id: a.id };
}),
/** Выплата вознаграждения агентству */
agencyPayout: 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: { agencyId: input.id }, orderBy: { periodEnd: 'desc' } });
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 } });
}),
// ───── заведения ─────
venues: p.query(async ({ ctx }) => {
const since = new Date(ctx.svc.now().getTime() - 30 * DAY);
const list = await ctx.db.partner.findMany({
orderBy: [{ active: 'desc' }, { name: 'asc' }],
include: { sales: { select: { id: true, name: true } }, _count: { select: { offers: { where: { active: true } }, members: true } } },
});
const ids = list.map((x) => x.id);
const [visits, last] = await Promise.all([
ctx.db.cardUsage.groupBy({ by: ['partnerId'], where: { partnerId: { in: ids }, offerId: null, createdAt: { gte: since } }, _count: true }),
ctx.db.cardUsage.groupBy({ by: ['partnerId'], where: { partnerId: { in: ids } }, _max: { createdAt: true } }),
]);
const v = new Map(visits.map((x) => [x.partnerId, x._count]));
const l = new Map(last.map((x) => [x.partnerId, x._max.createdAt]));
return list.map((x) => ({
id: x.id,
name: x.name,
category: x.category,
address: x.address,
active: x.active,
sales: x.sales,
offers: x._count.offers,
members: x._count.members,
visits30: v.get(x.id) ?? 0,
lastVisit: l.get(x.id) ?? null,
onMap: x.lat != null && x.lng != null,
}));
}),
venue: p.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const v = await ctx.db.partner.findUnique({
where: { id: input.id },
include: {
sales: { select: { id: true, name: true } },
offers: { orderBy: { sortOrder: 'asc' } },
members: { include: { user: { select: { firstName: true, lastName: true, username: true } } }, orderBy: { createdAt: 'asc' } },
},
});
if (!v) throw new TRPCError({ code: 'NOT_FOUND' });
const since = new Date(ctx.svc.now().getTime() - 8 * 7 * DAY);
const [recent, weekly] = await Promise.all([
ctx.db.cardUsage.findMany({
where: { partnerId: v.id },
orderBy: { createdAt: 'desc' },
take: 30,
include: { client: { include: { user: { select: { firstName: true, lastName: true, username: true } } } }, offer: { select: { title: true } } },
}),
ctx.db.cardUsage.findMany({ where: { partnerId: v.id, createdAt: { gte: since } }, select: { createdAt: true, offerId: true } }),
]);
const memberName = new Map(v.members.map((m) => [m.userId, name(m.user)]));
// проверки карты по неделям (последние 8)
const weeks = Array.from({ length: 8 }, (_, i) => ({ from: new Date(since.getTime() + i * 7 * DAY), checks: 0, redeemed: 0 }));
for (const u of weekly) {
const w = weeks[Math.min(7, Math.floor((u.createdAt.getTime() - since.getTime()) / (7 * DAY)))];
if (!w) continue;
if (u.offerId) w.redeemed++;
else w.checks++;
}
return {
id: v.id,
name: v.name,
category: v.category,
offer: v.offer as Record<string, string>,
address: v.address,
phone: v.phone,
lat: v.lat,
lng: v.lng,
note: v.note,
active: v.active,
sales: v.sales,
createdAt: v.createdAt,
ownerInviteUrl: v.ownerInviteCode ? proBotUrl(`po-${v.ownerInviteCode}`) : null,
offers: v.offers.map((o) => ({ id: o.id, title: o.title as Record<string, string>, minLevel: o.minLevel, limit: o.limit, limitCount: o.limitCount, active: o.active })),
members: v.members.map((m) => ({ id: m.id, role: m.role, name: name(m.user), username: m.user.username, since: m.createdAt })),
recent: recent.map((u) => ({ id: u.id, at: u.createdAt, client: name(u.client.user), clientId: u.clientId, level: u.level, offer: (u.offer?.title as Record<string, string> | undefined) ?? null, by: u.memberUserId ? (memberName.get(u.memberUserId) ?? null) : null })),
weeks,
};
}),
saveVenue: p
.input(
z.object({
id: z.string().optional(),
name: z.string().trim().min(1).max(100),
category: z.enum(CATEGORIES),
offer: localized,
address: z.string().max(200).nullable(),
phone: z.string().max(40).nullable(),
lat: z.number().min(-90).max(90).nullable(),
lng: z.number().min(-180).max(180).nullable(),
note: z.string().max(2000).nullable(),
active: z.boolean(),
salesId: z.string().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const v = id ? await ctx.db.partner.update({ where: { id }, data }) : await ctx.db.partner.create({ data });
return { id: v.id };
}),
saveOffer: p
.input(z.object({ id: z.string().optional(), partnerId: z.string(), title: localized.extend({ ru: z.string().trim().min(1).max(300) }), minLevel: z.enum(['BAREV_PLUS', 'ELITE']).nullable(), limit: z.enum(['NONE', 'ONCE', 'WEEK', 'MONTH']), limitCount: z.number().int().min(1).max(100), active: z.boolean() }))
.mutation(({ ctx, input }) => savePartnerOffer(ctx.svc, input).then((o) => ({ id: o.id }))),
/** Новая одноразовая ссылка для собственника (рабочий бот) */
ownerInvite: p.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => createOwnerInvite(ctx.svc, input.id)),
removeMember: p.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.partnerMember.delete({ where: { id: input.id } });
}),
/** Кому из команды можно приписать партнёра (продажники) */
salesTeam: p.query(({ ctx }) => ctx.db.adminUser.findMany({ where: { active: true, roles: { hasSome: ['SALES', 'SUPERADMIN'] } }, orderBy: { name: 'asc' }, select: { id: true, name: true } })),
});
+35
View File
@@ -205,6 +205,41 @@ app.get<{ Params: { fileId: string } }>('/files/bo/order-file/:fileId', async (r
return sendChatFile(reply, { fileKey: f.storageKey, tgFileId: null, tgBot: null, mime: f.mime, fileName: `order-photo.${f.mime.split('/')[1] ?? 'jpg'}` });
});
// ───── CRM продажника (этап 4) ─────
/** Продажник — свои лиды; суперадмин — все */
async function leadForStaff(req: FastifyRequest, leadId: string) {
const staff = await staffFromCookie(req);
if (!staff) return null;
const isSuper = staff.admin.roles.includes('SUPERADMIN');
if (!isSuper && !staff.admin.roles.includes('SALES')) return null;
const lead = await db.salesLead.findUnique({ where: { id: leadId } });
if (!lead || (!isSuper && lead.ownerId !== staff.admin.id)) return null;
return { staff, lead };
}
/** Фото витрины, меню, договор — в историю лида */
app.post<{ Params: { leadId: string } }>('/files/bo/lead/:leadId', async (req, reply) => {
const found = await leadForStaff(req, req.params.leadId);
if (!found) return reply.code(404).send({ error: 'NOT_FOUND' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
if (!CHAT_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const fileName = safeName(part.filename);
const fileKey = await putFile(`leads/${found.lead.id}`, body, part.mimetype, fileName);
const a = await db.salesActivity.create({
data: { leadId: found.lead.id, authorId: found.staff.admin.id, type: 'FILE', text: (multipartField(part, 'text') ?? '').trim().slice(0, 2000) || null, fileKey, fileName, mime: part.mimetype, size: body.length },
});
return { id: a.id };
});
app.get<{ Params: { activityId: string } }>('/files/bo/lead-file/:activityId', async (req, reply) => {
const a = await db.salesActivity.findUnique({ where: { id: req.params.activityId } });
if (!a?.fileKey || !(await leadForStaff(req, a.leadId))) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, { fileKey: a.fileKey, tgFileId: null, tgBot: null, mime: a.mime, fileName: a.fileName });
});
/** Мини-апп клиента: клиент видит только файлы своей переписки (без заметок команды) */
function clientFromInit(req: FastifyRequest) {
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
+2
View File
@@ -14,12 +14,14 @@
"@barev/ui": "workspace:*",
"@barev/web": "workspace:*",
"@tanstack/react-query": "^5.104.0",
"leaflet": "1.9.4",
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@barev/api": "workspace:*",
"@types/leaflet": "^1.9.22",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
+17 -3
View File
@@ -15,6 +15,10 @@ import { Orders } from './pages/Orders.tsx';
import { OrderCard } from './pages/OrderCard.tsx';
import { Providers } from './pages/Providers.tsx';
import { ProviderCard } from './pages/ProviderCard.tsx';
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';
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
export interface Me {
@@ -50,9 +54,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'], count: 'orders' },
{ path: '/providers', key: 'nav.providers', icon: 'providers', roles: ['SUPPORT'], group: 'work' },
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', stage: 4, roles: [], group: 'partners' },
{ path: '/venues', key: 'nav.venues', icon: 'venues', stage: 4, roles: [], group: 'partners' },
{ path: '/crm', key: 'nav.crm', icon: 'crm', stage: 4, roles: ['SALES'], group: 'partners' },
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', roles: [], group: 'partners' },
{ path: '/venues', key: 'nav.venues', icon: 'venues', roles: [], group: 'partners' },
{ path: '/crm', key: 'nav.crm', icon: 'crm', roles: ['SALES'], group: 'partners' },
{ path: '/products', key: 'nav.products', icon: 'products', stage: 5, roles: [], group: 'setup' },
{ path: '/journal', key: 'nav.journal', icon: 'journal', stage: 5, roles: [], group: 'setup' },
{ path: '/team', key: 'nav.team', icon: 'team', roles: [], group: 'setup' },
@@ -81,12 +85,22 @@ function Shell({ me }: { me: Me }) {
const orderId = match('/orders/:id', path)?.id;
const providerId = match('/providers/:id', path)?.id;
const canOrders = items.some((n) => n.path === '/orders');
const leadId = match('/crm/:id', path)?.id;
const agencyId = match('/agencies/:id', path)?.id;
const venueId = match('/venues/:id', path)?.id;
const canCrm = items.some((n) => n.path === '/crm');
if (path === '/') page = <Dashboard me={me} />;
else if ((path === '/chats' || threadId) && canChat) page = <Chats me={me} threadId={threadId} />;
else if (path === '/orders' && canOrders) page = <Orders me={me} />;
else if (orderId && canOrders) page = <OrderCard id={orderId} me={me} />;
else if (path === '/providers' && canOrders) page = <Providers me={me} />;
else if (providerId && canOrders) page = <ProviderCard id={providerId} me={me} />;
else if (path === '/crm' && canCrm) page = <Crm 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 === '/venues' && isSuper) page = <Venues />;
else if (venueId && isSuper) page = <VenueCard id={venueId} />;
else if (path === '/clients' && current) page = <Clients me={me} />;
else if (clientId && items.some((n) => n.path === '/clients')) page = <ClientCard id={clientId} me={me} />;
else if (path === '/team' && isSuper) page = <Team me={me} />;
+96
View File
@@ -0,0 +1,96 @@
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useEffect, useRef } from 'react';
/** Пастельные цвета этапов — как на сайте */
export const STAGE_COLORS: Record<string, string> = {
pink: '#f6c3d3',
blue: '#bcd0f2',
home: '#a8c38a',
learn: '#cfc8f5',
docs: '#f9cdb0',
perks: '#f7da6e',
};
export const STAGE_COLOR_KEYS = Object.keys(STAGE_COLORS);
const YEREVAN: [number, number] = [40.1792, 44.4991];
export interface MapPoint {
id: string;
lat: number;
lng: number;
title: string;
sub?: string | null;
/** заливка точки */
color: string;
/** подключённый партнёр — крупнее, со сливовой обводкой */
strong?: boolean;
dim?: boolean;
}
/**
* Карта на OpenStreetMap: точки-кружки (без картинок-маркеров), клик по точке — onPick.
* Если точек нет — Ереван целиком.
*/
export function MapView({ points, height = 420, onPick, focus }: { points: MapPoint[]; height?: number; onPick?: (id: string) => void; focus?: [number, number] | null }) {
const box = useRef<HTMLDivElement>(null);
const map = useRef<L.Map | null>(null);
const layer = useRef<L.LayerGroup | null>(null);
const pick = useRef(onPick);
pick.current = onPick;
useEffect(() => {
if (!box.current) return;
const m = L.map(box.current, { zoomControl: true, attributionControl: true }).setView(YEREVAN, 12);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap' }).addTo(m);
map.current = m;
layer.current = L.layerGroup().addTo(m);
return () => {
m.remove();
map.current = null;
};
}, []);
useEffect(() => {
const m = map.current;
const g = layer.current;
if (!m || !g) return;
g.clearLayers();
for (const p of points) {
const c = L.circleMarker([p.lat, p.lng], {
radius: p.strong ? 10 : 8,
color: p.strong ? '#4a1028' : '#5c5550',
weight: p.strong ? 3 : 1.5,
fillColor: p.color,
fillOpacity: p.dim ? 0.45 : 0.95,
});
c.bindTooltip(`<b>${escape(p.title)}</b>${p.sub ? `<br>${escape(p.sub)}` : ''}`, { direction: 'top', offset: [0, -6] });
c.on('click', () => pick.current?.(p.id));
c.addTo(g);
}
if (focus) m.setView(focus, 16);
else if (points.length) m.fitBounds(L.latLngBounds(points.map((p) => [p.lat, p.lng] as [number, number])).pad(0.25), { maxZoom: 15 });
}, [points, focus]);
return <div ref={box} className="bo-map" style={{ height }} />;
}
const escape = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
/** Координаты из «40.18, 44.51» или ссылки Google/Яндекс-карт */
export function parseCoords(raw: string): { lat: number; lng: number } | null {
const s = decodeURIComponent(raw.trim());
const patterns = [/@(-?\d+\.\d+),(-?\d+\.\d+)/, /[?&](?:q|query)=(-?\d+\.\d+),\s*(-?\d+\.\d+)/, /^(-?\d+\.\d+)\s*[,; ]\s*(-?\d+\.\d+)$/, /!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/];
for (const re of patterns) {
const m = s.match(re);
if (m) {
const lat = Number(m[1]);
const lng = Number(m[2]);
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) return { lat, lng };
}
}
// Яндекс: ll=lng,lat
const y = s.match(/ll=(-?\d+\.\d+)%2C(-?\d+\.\d+)|ll=(-?\d+\.\d+),(-?\d+\.\d+)/);
if (y) return { lat: Number(y[2] ?? y[4]), lng: Number(y[1] ?? y[3]) };
return null;
}
+361
View File
@@ -0,0 +1,361 @@
/*
* Этап 4: CRM продажника и партнёры.
* Воронка — колонки в пастельных цветах этапов, карточки лидов перетаскиваются.
*/
.bo-crm {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(250px, 1fr);
gap: 12px;
overflow-x: auto;
padding-bottom: 8px;
align-items: start;
}
.bo-crm__col {
background: color-mix(in srgb, var(--stage) 35%, var(--bc-bg));
border-radius: 24px;
padding: 10px;
min-height: 240px;
transition: box-shadow 0.15s;
}
.bo-crm__col[data-over] {
box-shadow: inset 0 0 0 2px var(--bc-accent);
}
.bo-crm__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px 10px;
font-family: var(--bc-font-head);
}
.bo-crm__head b {
font-size: 15px;
}
.bo-crm__head span {
min-width: 26px;
height: 22px;
padding: 0 8px;
border-radius: var(--bc-pill);
background: var(--stage);
display: inline-grid;
place-items: center;
font-size: 12px;
font-weight: 700;
}
.bo-crm__cards {
display: grid;
gap: 8px;
}
.bo-crm__empty {
margin: 0;
padding: 18px 8px;
border: 1.5px dashed rgba(30, 27, 26, 0.15);
border-radius: 16px;
text-align: center;
font-size: 13px;
color: var(--bc-ink-3);
}
.bo-lead {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
border-radius: 16px;
background: #fff;
color: inherit;
text-decoration: none;
cursor: grab;
box-shadow: 0 1px 0 rgba(30, 27, 26, 0.06);
}
.bo-lead:hover {
box-shadow: 0 8px 22px rgba(30, 27, 26, 0.09);
}
.bo-lead[data-dragging] {
opacity: 0.45;
}
.bo-lead__top {
display: flex;
align-items: center;
gap: 6px;
}
.bo-lead__name {
font-weight: 600;
font-size: 15px;
line-height: 1.3;
}
.bo-lead__task {
display: flex;
align-items: center;
gap: 5px;
margin-top: 2px;
font-size: 12px;
color: var(--bc-ok);
}
.bo-lead__task.is-overdue {
color: var(--bc-danger);
font-weight: 600;
}
/* задачи */
.bo-tasks {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.bo-tasks li {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
background: var(--bo-soft);
}
.bo-tasks li[data-overdue] {
background: var(--bc-pink);
}
.bo-tasks li[data-done] {
opacity: 0.55;
}
.bo-tasks li[data-done] b {
text-decoration: line-through;
}
.bo-tasks .bo-grow {
min-width: 0;
white-space: normal;
}
.bo-tasks__check input {
width: 20px;
height: 20px;
accent-color: var(--bc-ok);
}
.bo-tasks__due {
font-size: 13px;
color: var(--bc-ink-2);
white-space: nowrap;
}
.bo-tasks li[data-overdue] .bo-tasks__due {
color: var(--bc-danger);
font-weight: 600;
}
/* карта */
.bo-map {
width: 100%;
border-radius: 20px;
overflow: hidden;
z-index: 0;
}
.bo-maplegend {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
padding: 10px 6px 4px;
font-size: 13px;
}
.bo-maplegend i {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #5c5550;
vertical-align: -1px;
margin-right: 4px;
}
/* этапы в карточке лида — кликабельные */
.bo-steps--click li {
padding: 0;
}
.bo-steps--click button {
width: 100%;
padding: 10px 12px 9px;
border: 0;
background: none;
font: inherit;
font-size: 13px;
font-weight: 600;
color: inherit;
text-align: left;
cursor: pointer;
}
.bo-steps--click button:disabled {
cursor: default;
}
.bo-steps--click li:not([data-state='now']):hover {
background: #fff;
color: var(--bc-ink);
}
/* история контактов */
.bo-acts {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 14px;
}
.bo-acts li {
display: flex;
gap: 12px;
}
.bo-acts__icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--bo-soft);
flex: none;
}
.bo-acts li[data-type='CALL'] .bo-acts__icon {
background: var(--bc-blue);
color: var(--bc-blue-ink);
}
.bo-acts li[data-type='MEETING'] .bo-acts__icon {
background: var(--bc-learn);
color: var(--bc-learn-ink);
}
.bo-acts li[data-type='WON'] .bo-acts__icon {
background: var(--bc-home);
color: var(--bc-home-ink);
}
.bo-acts li[data-type='STAGE'] .bo-acts__icon {
background: var(--bc-perks);
}
.bo-acts__text {
display: block;
white-space: pre-wrap;
margin-top: 2px;
}
.bo-acts__img {
display: block;
max-width: 260px;
max-height: 200px;
margin-top: 6px;
border-radius: 14px;
object-fit: cover;
}
.bo-wonbox {
background: var(--bc-home);
color: var(--bc-home-ink);
}
.bo-wonbox .bo-hint {
color: inherit;
}
/* настройки воронки */
.bo-stages {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.bo-stages li {
display: flex;
align-items: center;
gap: 8px;
}
.bo-stages .bo-input {
flex: 1;
min-height: 36px;
padding: 4px 10px;
}
.bo-stages .bo-select {
width: 130px;
min-height: 36px;
padding: 4px 8px;
}
.bo-stages__arrows {
display: flex;
flex-direction: column;
}
.bo-stages__arrows button {
border: 0;
background: none;
padding: 0 2px;
cursor: pointer;
color: var(--bc-ink-2);
}
.bo-swatches {
display: flex;
gap: 3px;
}
.bo-swatches button {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1.5px solid transparent;
cursor: pointer;
}
.bo-swatches button[aria-pressed='true'] {
border-color: var(--bc-ink);
}
/* бонус в шапке CRM */
.bo-bonus {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
height: 46px;
padding: 0 16px;
border-radius: 16px;
border: 0;
background: var(--bc-perks);
color: var(--bc-perks-ink);
font: inherit;
line-height: 1.15;
cursor: pointer;
}
.bo-bonus:disabled {
cursor: default;
}
.bo-bonus b {
font-size: 17px;
}
/* активность заведения по неделям */
.bo-weeks {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 8px;
align-items: end;
height: 120px;
}
.bo-weeks > div {
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
gap: 4px;
}
.bo-weeks__bar {
width: 100%;
min-height: 3px;
border-radius: 8px 8px 4px 4px;
background: var(--bc-blue);
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
}
.bo-weeks__bar span {
background: var(--bc-accent);
}
@media (max-width: 860px) {
.bo-crm {
grid-auto-columns: 82%;
scroll-snap-type: x mandatory;
}
.bo-crm__col {
scroll-snap-align: start;
}
.bo-stages li {
flex-wrap: wrap;
}
}
+666
View File
@@ -387,6 +387,228 @@ const ru = {
'chat.details': 'Подробнее',
'chat.more': 'Ещё действия',
// ───── партнёры и CRM (этап 4) ─────
'crm.title': 'CRM',
'crm.newLead': 'Новый лид',
'crm.editLead': 'Лид',
'crm.stagesBtn': 'Воронка',
'crm.tab.board': 'Воронка',
'crm.tab.tasks': 'Задачи',
'crm.tab.map': 'Карта',
'crm.search': 'Название, контакт, телефон или адрес',
'crm.kind.all': 'Все',
'crm.kind.VENUE': 'Заведения',
'crm.kind.AGENCY': 'Агентства',
'crm.kind1.VENUE': 'Заведение',
'crm.kind1.AGENCY': 'Агентство',
'crm.owner': 'Продажник',
'crm.allSales': 'Все продажники',
'crm.noOwner': 'Без продажника',
'crm.ownerIs': 'ведёт {name}',
'crm.connected': 'Подключён',
'crm.connect': 'Подключение',
'crm.days': '{n} дн',
'crm.inStage': 'Сколько дней на этом этапе',
'crm.dropHere': 'Перетащите сюда',
'crm.dragHint': 'Перетаскивайте карточки между этапами. «Подключён» сразу создаёт партнёра или агентство и выдаёт ссылку, «Отказ» спросит причину.',
'crm.overdue': 'просрочена',
'crm.tasksOpen': 'Открытые',
'crm.tasksDone': 'Выполненные',
'crm.addTask': 'Задача',
'crm.noTasks': 'Открытых задач нет.',
'crm.noDoneTasks': 'Выполненных задач пока нет.',
'crm.noLead': 'без лида',
'crm.tasksHint': 'В срок задачи придёт напоминание в рабочий бот (если привязан Telegram).',
'crm.taskTitle': 'Что сделать',
'crm.taskDay': 'День',
'crm.taskTime': 'Время',
'crm.taskHint': 'Напоминание придёт в рабочий бот в это время (по Еревану).',
'crm.tp.call': 'Позвонить',
'crm.tp.meet': 'Встреча',
'crm.tp.send': 'Отправить условия',
'crm.tp.check': 'Узнать решение',
'crm.partnerOnMap': 'партнёр клуба',
'crm.legendPartner': 'подключённые партнёры',
'crm.legendLead': 'лиды (цвет этапа)',
'crm.mapHint': 'Точка появляется, когда у лида есть координаты.',
'crm.name': 'Название',
'crm.namePhVenue': 'Например: Lumen Coffee',
'crm.namePhAgency': 'Например: Domik Realty',
'crm.category': 'Тип',
'crm.contactName': 'Контактное лицо',
'crm.contactPh': 'Имя и должность',
'crm.address': 'Адрес',
'crm.note': 'Заметка',
'crm.convert': 'Подключить',
'crm.convertTitle': 'Подключить «{name}»',
'crm.convertVenueHint': 'Создадим партнёра клубной карты и ссылку для собственника: он откроет рабочий бот и сможет проверять карты клиентов. Предложения по уровням добавите в карточке заведения.',
'crm.convertAgencyHint': 'Создадим агентство с кодом и ссылку для его клиентов: по ней они получают подарочный месяц и закрепляются за агентством.',
'crm.agencyCode': 'Код агентства',
'crm.agencyCodeHint': 'Латиница и цифры, клиенты могут ввести его вручную',
'crm.convertedTitle': 'Подключено 🎉',
'crm.convertedVenue': 'Отправьте эту ссылку собственнику заведения. Ссылка одноразовая.',
'crm.convertedAgency': 'Эту ссылку агентство раздаёт клиентам.',
'crm.openVenue': 'Карточка заведения',
'crm.openAgency': 'Карточка агентства',
'crm.ownerLinked': 'Собственник уже в рабочем боте.',
'crm.ownerNotYet': 'Собственник ещё не открыл ссылку. Отправьте её ещё раз:',
'crm.agencyInfo': 'Код {code}, клиентов: {n}',
'crm.lostTitle': 'Почему отказ?',
'crm.lostReason': 'Причина',
'crm.lr.price': 'Не устроили условия',
'crm.lr.notNow': 'Не сейчас, вернуться позже',
'crm.lr.noAnswer': 'Не выходят на связь',
'crm.lr.competitor': 'Работают с конкурентом',
'crm.lostBanner': 'Отказ: {reason}',
'crm.stagesTitle': 'Этапы воронки',
'crm.stagesHint': 'Порядок — стрелками. Нужен хотя бы один открытый этап и один «Подключён». Этап с лидами при удалении скрывается, лиды остаются.',
'crm.addStage': 'Этап',
'crm.stageKind.OPEN': 'в работе',
'crm.stageKind.WON': 'подключён',
'crm.stageKind.LOST': 'отказ',
'crm.bonusMonth': 'Бонус за месяц',
'crm.bonusMineSuper': 'Бонус (ваш), месяц',
'crm.bonusNotSet': 'не настроен',
'crm.bonusVenues': 'Заведения: {n} → {sum}',
'crm.bonusAgencies': 'Агентства: {n} → {sum}',
'crm.bonusRevenue': '{pct}% выручки клиентов агентств → {sum}',
'crm.bonusTitle': 'Формула бонуса продажника',
'crm.bonusHint': 'Бонус за месяц = подключённые заведения × ставка + агентства × ставка + % выручки клиентов агентств продажника (первые N месяцев после подключения). Выручка — оплаченные заказы и подписки.',
'crm.bonusPerVenue': 'За заведение, ֏',
'crm.bonusPerAgency': 'За агентство, ֏',
'crm.bonusPct': '% выручки клиентов агентств',
'crm.bonusMonths': 'Сколько месяцев',
'crm.back': 'CRM',
'crm.created': 'с {date}',
'crm.stage': 'Этап',
'crm.history': 'История контактов',
'crm.noHistory': 'Пока пусто — запишите первый звонок или встречу.',
'crm.act.CALL': 'Звонок',
'crm.act.MEETING': 'Встреча',
'crm.act.NOTE': 'Заметка',
'crm.act.FILE': 'Файл',
'crm.act.STAGE': 'Этап',
'crm.act.WON': 'Подключён',
'crm.actPh.CALL': 'О чём договорились по телефону?',
'crm.actPh.MEETING': 'Как прошла встреча, что дальше?',
'crm.actPh.NOTE': 'Заметка для себя и команды',
'crm.addAct': 'Записать',
'crm.attach': 'Фото или файл',
'crm.tasks': 'Задачи',
'crm.noLeadTasks': 'Задач нет. Поставьте следующий шаг — придёт напоминание.',
'crm.contacts': 'Контакты',
'crm.noCoords': 'На карте нет точки.',
'crm.coordsPh': 'Ссылка Google Maps или «40.18, 44.51»',
'crm.coordsBad': 'Не нашли координаты — вставьте ссылку из Google Maps или числа через запятую.',
'crm.remove': 'Удалить лид',
'crm.removeConfirm': 'Удалить лид вместе с историей и задачами?',
'venue.cat.cafe': 'Кафе',
'venue.cat.restaurant': 'Ресторан',
'venue.cat.shop': 'Магазин',
'venue.cat.beauty': 'Красота',
'venue.cat.other': 'Другое',
'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.pct': '{n}% с заказов клиентов',
'ag.byS': 'подключил(а) {name}',
'ag.link': 'Ссылка для клиентов',
'ag.linkHint': 'По ссылке клиент открывает бота, получает подарочный месяц и закрепляется за агентством. Или вводит код {code} командой /code.',
'ag.clients': 'Клиенты',
'ag.giftsN': 'подарков: {n}',
'ag.noClients': 'Клиентов пока нет.',
'ag.orders': 'заказов: {n}',
'ag.toPay': 'Вознаграждение к выплате',
'ag.accrued': 'Начислено',
'ag.payouts': 'Выплаты',
'ag.payoutTitle': 'Выплата агентству',
'ag.balanceHint': 'Начислено и не выплачено: {sum}',
'ag.pctLabel': 'Вознаграждение, %',
'ag.sales': 'Подключил продажник',
'ag.contactPh': 'Имя, телефон, Telegram',
'ag.active': 'Агентство работает (код принимается)',
'vn.title': 'Заведения',
'vn.add': 'Новое заведение',
'vn.editTitle': 'Заведение',
'vn.empty': 'Заведений пока нет.',
'vn.col.name': 'Заведение',
'vn.col.offers': 'Предложения',
'vn.col.staff': 'Сотрудники',
'vn.col.visits': 'Проверки за 30 дн',
'vn.col.last': 'Последняя проверка',
'vn.hint': 'Проверка — сотрудник заведения отсканировал клубную карту клиента в рабочем боте.',
'vn.noOwner': 'нет собственника',
'vn.noOffers': 'нет предложений',
'vn.back': 'Все заведения',
'vn.ownerHint': 'Собственник ещё не подключён — отправьте ему одноразовую ссылку в рабочий бот.',
'vn.makeLink': 'Создать ссылку',
'vn.offers': 'Предложения по уровням',
'vn.addOffer': 'Предложение',
'vn.editOffer': 'Предложение',
'vn.noOffersHint': 'Добавьте, что положено членам клуба: скидку, подарок, комплимент.',
'vn.level.any': 'Любой член клуба',
'vn.level.BAREV_PLUS': 'Barev+ и Elite',
'vn.level.ELITE': 'Только Elite',
'vn.limit.NONE': 'без ограничений',
'vn.limit.ONCE': 'один раз',
'vn.limit.WEEK': '{n} р. в неделю',
'vn.limit.MONTH': '{n} р. в месяц',
'vn.limitOpt.NONE': 'Без ограничений',
'vn.limitOpt.ONCE': 'Только первый визит',
'vn.limitOpt.WEEK': 'N раз в неделю',
'vn.limitOpt.MONTH': 'N раз в месяц',
'vn.offerOff': 'выключено',
'vn.offerTitle': 'Что получает клиент',
'vn.offerTitleHint': 'Например: «Капучино в подарок» или «−15% на всё меню»',
'vn.forWhom': 'Для кого',
'vn.howOften': 'Как часто',
'vn.times': 'Сколько раз',
'vn.offerActive': 'Предложение действует',
'vn.activity': 'Активность',
'vn.weeks8': 'за 8 недель',
'vn.checks': 'проверки карты',
'vn.redeemed': 'списания предложений',
'vn.cardCheck': 'проверка карты',
'vn.staff': 'Сотрудники',
'vn.noStaff': 'Пока никого.',
'vn.role.OWNER': 'собственник',
'vn.role.STAFF': 'сотрудник',
'vn.removeStaff': 'Убрать {name} из заведения?',
'vn.staffHint': 'Сотрудников приглашает собственник в рабочем боте (QR на сутки).',
'vn.newOwnerLink': 'Ссылка собственнику',
'vn.newOwnerLinkHint': 'Новая одноразовая ссылка — например, если сменился собственник',
'vn.blurb': 'Коротко о скидке (на карточке у клиента)',
'vn.blurbHint': 'Например: «−10% по клубной карте»',
'vn.coords': 'Точка на карте',
'vn.activeLabel': 'Заведение видно клиентам',
'err.LEAD_NOT_CONVERTED': 'Чтобы перевести в «Подключён», нажмите «Подключить» — так создастся партнёр.',
'err.ALREADY_CONVERTED': 'Лид уже подключён.',
'err.AGENCY_CODE_FORMAT': 'Код — латиница, цифры и «_», до 32 символов.',
'err.AGENCY_CODE_TAKEN': 'Такой код уже есть у другого агентства.',
'err.STAGES_INVALID': 'Нужен хотя бы один открытый этап и один «Подключён».',
'audit.bo.crm.create': 'добавил(а) лид',
'audit.bo.crm.update': 'изменил(а) лид',
'audit.bo.crm.move': 'перевёл(а) лид на другой этап',
'audit.bo.crm.convert': 'подключил(а) партнёра',
'audit.bo.crm.addActivity': 'записал(а) контакт',
'audit.bo.crm.saveStages': 'изменил(а) воронку',
'audit.bo.crm.setBonusRules': 'изменил(а) формулу бонуса',
'audit.bo.partners.saveAgency': 'изменил(а) агентство',
'audit.bo.partners.agencyPayout': 'отметил(а) выплату агентству',
'audit.bo.partners.saveVenue': 'изменил(а) заведение',
'audit.bo.partners.saveOffer': 'изменил(а) предложение заведения',
'audit.bo.partners.ownerInvite': 'выпустил(а) ссылку собственнику',
'audit.bo.partners.removeMember': 'убрал(а) сотрудника заведения',
// ───── заказы и исполнители (этап 3) ─────
'ord.title': 'Заказы',
'ord.new': 'Новый заказ',
@@ -1031,6 +1253,228 @@ const en: Dict = {
'chat.details': 'Details',
'chat.more': 'More actions',
// ───── партнёры и CRM (этап 4) ─────
'crm.title': 'CRM',
'crm.newLead': 'New lead',
'crm.editLead': 'Lead',
'crm.stagesBtn': 'Pipeline',
'crm.tab.board': 'Pipeline',
'crm.tab.tasks': 'Tasks',
'crm.tab.map': 'Map',
'crm.search': 'Name, contact, phone or address',
'crm.kind.all': 'All',
'crm.kind.VENUE': 'Venues',
'crm.kind.AGENCY': 'Agencies',
'crm.kind1.VENUE': 'Venue',
'crm.kind1.AGENCY': 'Agency',
'crm.owner': 'Sales rep',
'crm.allSales': 'All sales reps',
'crm.noOwner': 'Unassigned',
'crm.ownerIs': 'owned by {name}',
'crm.connected': 'Connected',
'crm.connect': 'Connecting',
'crm.days': '{n} d',
'crm.inStage': 'Days in this stage',
'crm.dropHere': 'Drop here',
'crm.dragHint': 'Drag cards between stages. “Connected” creates the partner or agency and gives you a link; “Lost” asks for a reason.',
'crm.overdue': 'overdue',
'crm.tasksOpen': 'Open',
'crm.tasksDone': 'Done',
'crm.addTask': 'Task',
'crm.noTasks': 'No open tasks.',
'crm.noDoneTasks': 'No completed tasks yet.',
'crm.noLead': 'no lead',
'crm.tasksHint': 'When a task is due you get a reminder in the work bot (if Telegram is linked).',
'crm.taskTitle': 'What to do',
'crm.taskDay': 'Day',
'crm.taskTime': 'Time',
'crm.taskHint': 'A reminder comes to the work bot at this time (Yerevan).',
'crm.tp.call': 'Call',
'crm.tp.meet': 'Meeting',
'crm.tp.send': 'Send the terms',
'crm.tp.check': 'Ask for a decision',
'crm.partnerOnMap': 'club partner',
'crm.legendPartner': 'connected partners',
'crm.legendLead': 'leads (stage colour)',
'crm.mapHint': 'A dot appears once a lead has coordinates.',
'crm.name': 'Name',
'crm.namePhVenue': 'E.g. Lumen Coffee',
'crm.namePhAgency': 'E.g. Domik Realty',
'crm.category': 'Type',
'crm.contactName': 'Contact person',
'crm.contactPh': 'Name and position',
'crm.address': 'Address',
'crm.note': 'Note',
'crm.convert': 'Connect',
'crm.convertTitle': 'Connect “{name}”',
'crm.convertVenueHint': 'We’ll create a club card partner and an owner link: the owner opens the work bot and can check client cards. Add offers in the venue card.',
'crm.convertAgencyHint': 'We’ll create the agency with a code and a client link: clients get a gift month and are attached to the agency.',
'crm.agencyCode': 'Agency code',
'crm.agencyCodeHint': 'Latin letters and digits; clients can type it in',
'crm.convertedTitle': 'Connected 🎉',
'crm.convertedVenue': 'Send this link to the venue owner. It works once.',
'crm.convertedAgency': 'The agency shares this link with clients.',
'crm.openVenue': 'Venue card',
'crm.openAgency': 'Agency card',
'crm.ownerLinked': 'The owner is already in the work bot.',
'crm.ownerNotYet': 'The owner hasn’t opened the link yet. Send it again:',
'crm.agencyInfo': 'Code {code}, clients: {n}',
'crm.lostTitle': 'Why lost?',
'crm.lostReason': 'Reason',
'crm.lr.price': 'Terms didn’t fit',
'crm.lr.notNow': 'Not now, come back later',
'crm.lr.noAnswer': 'Not responding',
'crm.lr.competitor': 'Work with a competitor',
'crm.lostBanner': 'Lost: {reason}',
'crm.stagesTitle': 'Pipeline stages',
'crm.stagesHint': 'Reorder with arrows. You need at least one open and one “Connected” stage. A stage with leads is hidden, not deleted.',
'crm.addStage': 'Stage',
'crm.stageKind.OPEN': 'open',
'crm.stageKind.WON': 'connected',
'crm.stageKind.LOST': 'lost',
'crm.bonusMonth': 'Bonus this month',
'crm.bonusMineSuper': 'Bonus (yours), month',
'crm.bonusNotSet': 'not set',
'crm.bonusVenues': 'Venues: {n} → {sum}',
'crm.bonusAgencies': 'Agencies: {n} → {sum}',
'crm.bonusRevenue': '{pct}% of agency client revenue → {sum}',
'crm.bonusTitle': 'Sales bonus formula',
'crm.bonusHint': 'Monthly bonus = connected venues × rate + agencies × rate + % of revenue from the rep’s agency clients (first N months). Revenue = paid orders and subscriptions.',
'crm.bonusPerVenue': 'Per venue, ֏',
'crm.bonusPerAgency': 'Per agency, ֏',
'crm.bonusPct': '% of agency client revenue',
'crm.bonusMonths': 'For how many months',
'crm.back': 'CRM',
'crm.created': 'since {date}',
'crm.stage': 'Stage',
'crm.history': 'Contact history',
'crm.noHistory': 'Empty so far — log the first call or meeting.',
'crm.act.CALL': 'Call',
'crm.act.MEETING': 'Meeting',
'crm.act.NOTE': 'Note',
'crm.act.FILE': 'File',
'crm.act.STAGE': 'Stage',
'crm.act.WON': 'Connected',
'crm.actPh.CALL': 'What did you agree on the call?',
'crm.actPh.MEETING': 'How did the meeting go, what’s next?',
'crm.actPh.NOTE': 'A note for yourself and the team',
'crm.addAct': 'Log it',
'crm.attach': 'Photo or file',
'crm.tasks': 'Tasks',
'crm.noLeadTasks': 'No tasks. Set the next step — you’ll get a reminder.',
'crm.contacts': 'Contacts',
'crm.noCoords': 'Not on the map yet.',
'crm.coordsPh': 'Google Maps link or “40.18, 44.51”',
'crm.coordsBad': 'No coordinates found — paste a Google Maps link or two numbers.',
'crm.remove': 'Delete lead',
'crm.removeConfirm': 'Delete the lead with its history and tasks?',
'venue.cat.cafe': 'Café',
'venue.cat.restaurant': 'Restaurant',
'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.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.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.clients': 'Clients',
'ag.giftsN': 'gifts: {n}',
'ag.noClients': 'No clients yet.',
'ag.orders': 'orders: {n}',
'ag.toPay': 'Reward to pay',
'ag.accrued': 'Accrued',
'ag.payouts': 'Payouts',
'ag.payoutTitle': 'Payout to agency',
'ag.balanceHint': 'Accrued and unpaid: {sum}',
'ag.pctLabel': 'Reward, %',
'ag.sales': 'Connected by',
'ag.contactPh': 'Name, phone, Telegram',
'ag.active': 'Agency active (code accepted)',
'vn.title': 'Venues',
'vn.add': 'New venue',
'vn.editTitle': 'Venue',
'vn.empty': 'No venues yet.',
'vn.col.name': 'Venue',
'vn.col.offers': 'Offers',
'vn.col.staff': 'Staff',
'vn.col.visits': 'Checks, 30 d',
'vn.col.last': 'Last check',
'vn.hint': 'A check = venue staff scanned a client’s club card in the work bot.',
'vn.noOwner': 'no owner',
'vn.noOffers': 'no offers',
'vn.back': 'All venues',
'vn.ownerHint': 'The owner isn’t connected yet — send them a one-time work bot link.',
'vn.makeLink': 'Create link',
'vn.offers': 'Offers by level',
'vn.addOffer': 'Offer',
'vn.editOffer': 'Offer',
'vn.noOffersHint': 'Add what members get: a discount, a gift, a treat.',
'vn.level.any': 'Any member',
'vn.level.BAREV_PLUS': 'Barev+ and Elite',
'vn.level.ELITE': 'Elite only',
'vn.limit.NONE': 'unlimited',
'vn.limit.ONCE': 'once',
'vn.limit.WEEK': '{n}× a week',
'vn.limit.MONTH': '{n}× a month',
'vn.limitOpt.NONE': 'Unlimited',
'vn.limitOpt.ONCE': 'First visit only',
'vn.limitOpt.WEEK': 'N times a week',
'vn.limitOpt.MONTH': 'N times a month',
'vn.offerOff': 'off',
'vn.offerTitle': 'What the client gets',
'vn.offerTitleHint': 'E.g. “Free cappuccino” or “−15% on the menu”',
'vn.forWhom': 'For whom',
'vn.howOften': 'How often',
'vn.times': 'Times',
'vn.offerActive': 'Offer active',
'vn.activity': 'Activity',
'vn.weeks8': 'last 8 weeks',
'vn.checks': 'card checks',
'vn.redeemed': 'offers redeemed',
'vn.cardCheck': 'card check',
'vn.staff': 'Staff',
'vn.noStaff': 'Nobody yet.',
'vn.role.OWNER': 'owner',
'vn.role.STAFF': 'staff',
'vn.removeStaff': 'Remove {name} from the venue?',
'vn.staffHint': 'Staff are invited by the owner in the work bot (24-hour QR).',
'vn.newOwnerLink': 'Owner link',
'vn.newOwnerLinkHint': 'A new one-time link — e.g. if the owner changed',
'vn.blurb': 'Short offer line (shown to clients)',
'vn.blurbHint': 'E.g. “−10% with the club card”',
'vn.coords': 'Map point',
'vn.activeLabel': 'Visible to clients',
'err.LEAD_NOT_CONVERTED': 'To move to “Connected”, use “Connect” — it creates the partner.',
'err.ALREADY_CONVERTED': 'The lead is already connected.',
'err.AGENCY_CODE_FORMAT': 'Code: Latin letters, digits and “_”, up to 32.',
'err.AGENCY_CODE_TAKEN': 'This code is already taken.',
'err.STAGES_INVALID': 'You need at least one open and one “Connected” stage.',
'audit.bo.crm.create': 'added a lead',
'audit.bo.crm.update': 'edited a lead',
'audit.bo.crm.move': 'moved a lead',
'audit.bo.crm.convert': 'connected a partner',
'audit.bo.crm.addActivity': 'logged a contact',
'audit.bo.crm.saveStages': 'edited the pipeline',
'audit.bo.crm.setBonusRules': 'changed the bonus formula',
'audit.bo.partners.saveAgency': 'edited an agency',
'audit.bo.partners.agencyPayout': 'recorded an agency payout',
'audit.bo.partners.saveVenue': 'edited a venue',
'audit.bo.partners.saveOffer': 'edited a venue offer',
'audit.bo.partners.ownerInvite': 'issued an owner link',
'audit.bo.partners.removeMember': 'removed venue staff',
// ───── заказы и исполнители (этап 3) ─────
'ord.title': 'Orders',
'ord.new': 'New order',
@@ -1672,6 +2116,228 @@ const hy: Dict = {
'chat.details': 'Մանրամասներ',
'chat.more': 'Այլ գործողություններ',
// ───── партнёры и CRM (этап 4) ─────
'crm.title': 'CRM',
'crm.newLead': 'Նոր լիդ',
'crm.editLead': 'Լիդ',
'crm.stagesBtn': 'Ձագար',
'crm.tab.board': 'Ձագար',
'crm.tab.tasks': 'Առաջադրանքներ',
'crm.tab.map': 'Քարտեզ',
'crm.search': 'Անուն, կոնտակտ, հեռախոս կամ հասցե',
'crm.kind.all': 'Բոլորը',
'crm.kind.VENUE': 'Հաստատություններ',
'crm.kind.AGENCY': 'Գործակալություններ',
'crm.kind1.VENUE': 'Հաստատություն',
'crm.kind1.AGENCY': 'Գործակալություն',
'crm.owner': 'Վաճառող',
'crm.allSales': 'Բոլոր վաճառողները',
'crm.noOwner': 'Առանց վաճառողի',
'crm.ownerIs': 'վարում է {name}',
'crm.connected': 'Միացված',
'crm.connect': 'Միացում',
'crm.days': '{n} օր',
'crm.inStage': 'Օրեր այս փուլում',
'crm.dropHere': 'Քաշեք այստեղ',
'crm.dragHint': 'Քաշեք քարտերը փուլերի միջև։ «Միացված»-ը ստեղծում է գործընկեր կամ գործակալություն։',
'crm.overdue': 'ժամկետանց',
'crm.tasksOpen': 'Բաց',
'crm.tasksDone': 'Կատարված',
'crm.addTask': 'Առաջադրանք',
'crm.noTasks': 'Բաց առաջադրանքներ չկան։',
'crm.noDoneTasks': 'Կատարված առաջադրանքներ դեռ չկան։',
'crm.noLead': 'առանց լիդի',
'crm.tasksHint': 'Ժամկետին հիշեցում կգա աշխատանքային բոտում։',
'crm.taskTitle': 'Ինչ անել',
'crm.taskDay': 'Օր',
'crm.taskTime': 'Ժամ',
'crm.taskHint': 'Հիշեցումը կգա այդ ժամին (Երևան)։',
'crm.tp.call': 'Զանգահարել',
'crm.tp.meet': 'Հանդիպում',
'crm.tp.send': 'Ուղարկել պայմանները',
'crm.tp.check': 'Իմանալ որոշումը',
'crm.partnerOnMap': 'ակումբի գործընկեր',
'crm.legendPartner': 'միացված գործընկերներ',
'crm.legendLead': 'լիդեր (փուլի գույն)',
'crm.mapHint': 'Կետը հայտնվում է կոորդինատներ ունենալիս։',
'crm.name': 'Անվանում',
'crm.namePhVenue': 'Օրինակ՝ Lumen Coffee',
'crm.namePhAgency': 'Օրինակ՝ Domik Realty',
'crm.category': 'Տեսակ',
'crm.contactName': 'Կոնտակտային անձ',
'crm.contactPh': 'Անուն և պաշտոն',
'crm.address': 'Հասցե',
'crm.note': 'Նշում',
'crm.convert': 'Միացնել',
'crm.convertTitle': 'Միացնել «{name}»',
'crm.convertVenueHint': 'Կստեղծենք գործընկեր և հղում սեփականատիրոջ համար։',
'crm.convertAgencyHint': 'Կստեղծենք գործակալություն կոդով և հղում հաճախորդների համար։',
'crm.agencyCode': 'Գործակալության կոդ',
'crm.agencyCodeHint': 'Լատինատառ և թվեր',
'crm.convertedTitle': 'Միացված է 🎉',
'crm.convertedVenue': 'Ուղարկեք այս հղումը սեփականատիրոջը։ Միանգամյա է։',
'crm.convertedAgency': 'Այս հղումը գործակալությունը տալիս է հաճախորդներին։',
'crm.openVenue': 'Հաստատության քարտ',
'crm.openAgency': 'Գործակալության քարտ',
'crm.ownerLinked': 'Սեփականատերն արդեն բոտում է։',
'crm.ownerNotYet': 'Սեփականատերը դեռ չի բացել հղումը։ Ուղարկեք կրկին՝',
'crm.agencyInfo': 'Կոդ {code}, հաճախորդներ՝ {n}',
'crm.lostTitle': 'Ինչու՞ հրաժարում',
'crm.lostReason': 'Պատճառ',
'crm.lr.price': 'Պայմանները չհամապատասխանեցին',
'crm.lr.notNow': 'Ոչ հիմա',
'crm.lr.noAnswer': 'Չեն պատասխանում',
'crm.lr.competitor': 'Աշխատում են մրցակցի հետ',
'crm.lostBanner': 'Հրաժարում՝ {reason}',
'crm.stagesTitle': 'Ձագարի փուլեր',
'crm.stagesHint': 'Հերթականությունը՝ սլաքներով։',
'crm.addStage': 'Փուլ',
'crm.stageKind.OPEN': 'ընթացքում',
'crm.stageKind.WON': 'միացված',
'crm.stageKind.LOST': 'հրաժարում',
'crm.bonusMonth': 'Բոնուս այս ամիս',
'crm.bonusMineSuper': 'Բոնուս (ձեր), ամիս',
'crm.bonusNotSet': 'կարգավորված չէ',
'crm.bonusVenues': 'Հաստատություններ՝ {n} → {sum}',
'crm.bonusAgencies': 'Գործակալություններ՝ {n} → {sum}',
'crm.bonusRevenue': '{pct}% եկամուտից → {sum}',
'crm.bonusTitle': 'Վաճառողի բոնուսի բանաձև',
'crm.bonusHint': 'Բոնուս = հաստատություններ × դրույք + գործակալություններ × դրույք + % եկամուտից։',
'crm.bonusPerVenue': 'Հաստատության համար, ֏',
'crm.bonusPerAgency': 'Գործակալության համար, ֏',
'crm.bonusPct': '% եկամուտից',
'crm.bonusMonths': 'Քանի ամիս',
'crm.back': 'CRM',
'crm.created': '{date}-ից',
'crm.stage': 'Փուլ',
'crm.history': 'Կոնտակտների պատմություն',
'crm.noHistory': 'Դեռ դատարկ է։',
'crm.act.CALL': 'Զանգ',
'crm.act.MEETING': 'Հանդիպում',
'crm.act.NOTE': 'Նշում',
'crm.act.FILE': 'Ֆայլ',
'crm.act.STAGE': 'Փուլ',
'crm.act.WON': 'Միացված',
'crm.actPh.CALL': 'Ինչի՞ մասին պայմանավորվեցիք',
'crm.actPh.MEETING': 'Ինչպե՞ս անցավ հանդիպումը',
'crm.actPh.NOTE': 'Նշում ձեզ և թիմի համար',
'crm.addAct': 'Գրանցել',
'crm.attach': 'Լուսանկար կամ ֆայլ',
'crm.tasks': 'Առաջադրանքներ',
'crm.noLeadTasks': 'Առաջադրանքներ չկան։',
'crm.contacts': 'Կոնտակտներ',
'crm.noCoords': 'Քարտեզում չկա։',
'crm.coordsPh': 'Google Maps հղում կամ «40.18, 44.51»',
'crm.coordsBad': 'Կոորդինատներ չգտնվեցին։',
'crm.remove': 'Ջնջել լիդը',
'crm.removeConfirm': 'Ջնջե՞լ լիդը։',
'venue.cat.cafe': 'Սրճարան',
'venue.cat.restaurant': 'Ռեստորան',
'venue.cat.shop': 'Խանութ',
'venue.cat.beauty': 'Գեղեցկություն',
'venue.cat.other': 'Այլ',
'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.pct': '{n}% հաճախորդների պատվերներից',
'ag.byS': 'միացրել է {name}',
'ag.link': 'Հղում հաճախորդների համար',
'ag.linkHint': 'Հաճախորդը բացում է բոտը և ստանում նվեր ամիս։ Կամ մուտքագրում է {code} կոդը։',
'ag.clients': 'Հաճախորդներ',
'ag.giftsN': 'նվերներ՝ {n}',
'ag.noClients': 'Հաճախորդներ դեռ չկան։',
'ag.orders': 'պատվերներ՝ {n}',
'ag.toPay': 'Վճարման ենթակա պարգև',
'ag.accrued': 'Հաշվեգրված',
'ag.payouts': 'Վճարումներ',
'ag.payoutTitle': 'Վճարում գործակալությանը',
'ag.balanceHint': 'Չվճարված՝ {sum}',
'ag.pctLabel': 'Պարգև, %',
'ag.sales': 'Միացրել է',
'ag.contactPh': 'Անուն, հեռախոս, Telegram',
'ag.active': 'Գործակալությունն աշխատում է',
'vn.title': 'Հաստատություններ',
'vn.add': 'Նոր հաստատություն',
'vn.editTitle': 'Հաստատություն',
'vn.empty': 'Հաստատություններ դեռ չկան։',
'vn.col.name': 'Հաստատություն',
'vn.col.offers': 'Առաջարկներ',
'vn.col.staff': 'Աշխատակիցներ',
'vn.col.visits': 'Ստուգումներ, 30 օր',
'vn.col.last': 'Վերջին ստուգումը',
'vn.hint': 'Ստուգում՝ աշխատակիցը սկանավորել է ակումբային քարտը։',
'vn.noOwner': 'սեփականատեր չկա',
'vn.noOffers': 'առաջարկներ չկան',
'vn.back': 'Բոլոր հաստատությունները',
'vn.ownerHint': 'Սեփականատերը դեռ միացված չէ՝ ուղարկեք միանգամյա հղում։',
'vn.makeLink': 'Ստեղծել հղում',
'vn.offers': 'Առաջարկներ ըստ մակարդակի',
'vn.addOffer': 'Առաջարկ',
'vn.editOffer': 'Առաջարկ',
'vn.noOffersHint': 'Ավելացրեք, թե ինչ են ստանում անդամները։',
'vn.level.any': 'Ցանկացած անդամ',
'vn.level.BAREV_PLUS': 'Barev+ և Elite',
'vn.level.ELITE': 'Միայն Elite',
'vn.limit.NONE': 'առանց սահմանափակման',
'vn.limit.ONCE': 'մեկ անգամ',
'vn.limit.WEEK': 'շաբաթական {n} անգամ',
'vn.limit.MONTH': 'ամսական {n} անգամ',
'vn.limitOpt.NONE': 'Առանց սահմանափակման',
'vn.limitOpt.ONCE': 'Միայն առաջին այցը',
'vn.limitOpt.WEEK': 'N անգամ շաբաթական',
'vn.limitOpt.MONTH': 'N անգամ ամսական',
'vn.offerOff': 'անջատված',
'vn.offerTitle': 'Ինչ է ստանում հաճախորդը',
'vn.offerTitleHint': 'Օրինակ՝ «Կապուչինո նվեր»',
'vn.forWhom': 'Ում համար',
'vn.howOften': 'Որքան հաճախ',
'vn.times': 'Քանի անգամ',
'vn.offerActive': 'Առաջարկն ակտիվ է',
'vn.activity': 'Ակտիվություն',
'vn.weeks8': '8 շաբաթում',
'vn.checks': 'քարտի ստուգումներ',
'vn.redeemed': 'օգտագործված առաջարկներ',
'vn.cardCheck': 'քարտի ստուգում',
'vn.staff': 'Աշխատակիցներ',
'vn.noStaff': 'Դեռ ոչ ոք։',
'vn.role.OWNER': 'սեփականատեր',
'vn.role.STAFF': 'աշխատակից',
'vn.removeStaff': 'Հեռացնե՞լ {name}-ին։',
'vn.staffHint': 'Աշխատակիցներին հրավիրում է սեփականատերը։',
'vn.newOwnerLink': 'Հղում սեփականատիրոջը',
'vn.newOwnerLinkHint': 'Նոր միանգամյա հղում',
'vn.blurb': 'Կարճ նկարագրություն',
'vn.blurbHint': 'Օրինակ՝ «−10% ակումբային քարտով»',
'vn.coords': 'Քարտեզի կետ',
'vn.activeLabel': 'Տեսանելի է հաճախորդներին',
'err.LEAD_NOT_CONVERTED': 'Օգտագործեք «Միացնել»։',
'err.ALREADY_CONVERTED': 'Լիդն արդեն միացված է։',
'err.AGENCY_CODE_FORMAT': 'Կոդ՝ լատինատառ և թվեր։',
'err.AGENCY_CODE_TAKEN': 'Այս կոդն արդեն զբաղված է։',
'err.STAGES_INVALID': 'Անհրաժեշտ է գոնե մեկ բաց և մեկ «Միացված» փուլ։',
'audit.bo.crm.create': 'ավելացրեց լիդ',
'audit.bo.crm.update': 'փոխեց լիդը',
'audit.bo.crm.move': 'տեղափոխեց լիդը',
'audit.bo.crm.convert': 'միացրեց գործընկեր',
'audit.bo.crm.addActivity': 'գրանցեց կոնտակտ',
'audit.bo.crm.saveStages': 'փոխեց ձագարը',
'audit.bo.crm.setBonusRules': 'փոխեց բոնուսի բանաձևը',
'audit.bo.partners.saveAgency': 'փոխեց գործակալությունը',
'audit.bo.partners.agencyPayout': 'նշեց վճարում գործակալությանը',
'audit.bo.partners.saveVenue': 'փոխեց հաստատությունը',
'audit.bo.partners.saveOffer': 'փոխեց առաջարկը',
'audit.bo.partners.ownerInvite': 'հղում տվեց սեփականատիրոջը',
'audit.bo.partners.removeMember': 'հեռացրեց աշխատակցին',
// ───── заказы и исполнители (этап 3) ─────
'ord.title': 'Պատվերներ',
'ord.new': 'Նոր պատվեր',
+1
View File
@@ -2,6 +2,7 @@ import '@barev/ui/styles.css';
import './styles.css';
import './chats.css';
import './orders.css';
import './crm.css';
import { ApiProvider, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+354
View File
@@ -0,0 +1,354 @@
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, PlanBadge, Skeleton } from '../components/kit.tsx';
function CopyLink({ url }: { url: string }) {
const { t } = useT();
const toast = useToast();
return (
<div className="bo-linkbox">
<code>{url}</code>
<button
type="button"
className="bo-btn bo-btn--sm"
onClick={() => {
void navigator.clipboard?.writeText(url);
toast(t('ui.copied'));
}}
>
{t('ui.copy')}
</button>
</div>
);
}
export { CopyLink };
export function Agencies() {
const { t, amd } = useT();
const trpc = useTRPC();
const list = useQuery(trpc.bo.partners.agencies.queryOptions());
const [creating, setCreating] = useState(false);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('ag.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('ag.add')}
</button>
</div>
{!list.data ? (
<Skeleton h={240} />
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('ag.empty')}</div>
) : (
<div className="bo-panel" style={{ padding: 8 }}>
<table className="bo-table bo-ptable">
<thead>
<tr>
<th>{t('ag.col.name')}</th>
<th className="bo-r">{t('ag.col.clients')}</th>
<th className="bo-r">{t('ag.col.gifts')}</th>
<th className="bo-r">{t('ag.col.month')}</th>
<th className="bo-r">{t('ag.col.balance')}</th>
</tr>
</thead>
<tbody>
{list.data.map((a) => (
<tr key={a.id} onClick={() => navigate(`/agencies/${a.id}`)} data-off={!a.active || undefined}>
<td>
<a href={`#/agencies/${a.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
<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>
{!a.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
</span>
</a>
</td>
<td className="bo-r bo-num">{a.clients}</td>
<td className="bo-r bo-num">{a.gifts}</td>
<td className="bo-r bo-num">{amd(a.thisMonth)}</td>
<td className="bo-r">
<b className="bo-num">{amd(a.balance)}</b>
</td>
</tr>
))}
</tbody>
</table>
<p className="bo-hint" style={{ padding: '10px 12px 4px' }}>
{t('ag.hint')}
</p>
</div>
)}
{creating && <AgencyDialog onClose={() => setCreating(false)} />}
</>
);
}
export function AgencyCard({ id }: { id: string }) {
const { t, amd, date, locale } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const a = useQuery(trpc.bo.partners.agency.queryOptions({ id }));
const [dlg, setDlg] = useState<'edit' | 'payout' | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.partners.agency.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.partners.agencies.queryKey() });
};
const back = (
<a className="bo-back" href="#/agencies">
<I name="back" size={16} />
{t('ag.back')}
</a>
);
if (!a.data)
return (
<>
{back}
{a.isError ? <div className="bo-panel bo-empty">{errText(t, a.error)}</div> : <Skeleton h={300} />}
</>
);
const d = a.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 className="bo-num">{d.code}</span>
<span>{t('ag.pct', { n: d.commissionPct })}</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>}
</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('ag.link')}</h2>
</div>
<p className="bo-hint" style={{ marginBottom: 10 }}>
{t('ag.linkHint', { code: d.code })}
</p>
<CopyLink url={d.link} />
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ag.clients')}</h2>
<span className="bo-faint bo-small">
{d.clients.length} · {t('ag.giftsN', { n: d.gifts })}
</span>
</div>
{d.clients.length === 0 ? (
<p className="bo-hint">{t('ag.noClients')}</p>
) : (
<ul className="bo-list">
{d.clients.map((c) => (
<li key={c.id}>
<a href={`#/clients/${c.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>{c.name}</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{c.attachedAt ? date(c.attachedAt) : '—'} · {t('ag.orders', { n: c.orders })}
</span>
</a>
<PlanBadge plan={c.plan} t={t} />
</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('ag.toPay')}
</span>
<div className="bo-bignum" style={{ margin: '6px 0 8px' }}>
{amd(d.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.accrued)}</b>
</span>
<span>
{t('prv.paidTotal')} <b className="bo-num">{amd(d.paid)}</b>
</span>
</div>
<button type="button" className="bo-btn bo-btn--sm" disabled={d.balance <= 0} onClick={() => setDlg('payout')}>
{t('prv.payout')}
</button>
{d.months.length > 0 && (
<ul className="bo-list" style={{ marginTop: 12 }}>
{d.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 })}
</span>
<b className="bo-num">{amd(m.amount)}</b>
</li>
))}
</ul>
)}
{d.payouts.length > 0 && (
<p className="bo-small" style={{ marginTop: 10, opacity: 0.8 }}>
{t('ag.payouts')}: {d.payouts.slice(0, 5).map((x) => `${amd(x.amount)}${x.paidAt ? ` (${date(x.paidAt)})` : ''}`).join(', ')}
</p>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('crm.contacts')}</h2>
</div>
<p style={{ margin: 0 }}>{d.contact ?? '—'}</p>
{d.note && <p className="bo-side-panel__note" style={{ marginTop: 12 }}>{d.note}</p>}
</section>
</div>
</div>
{dlg === 'edit' && <AgencyDialog agency={d} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'payout' && <AgencyPayout id={d.id} balance={d.balance} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
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 }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const team = useQuery(trpc.bo.partners.salesTeam.queryOptions());
const [f, setF] = useState({
code: agency?.code ?? '',
name: agency?.name ?? '',
commissionPct: String(agency?.commissionPct ?? 10),
contact: agency?.contact ?? '',
note: agency?.note ?? '',
active: agency?.active ?? true,
salesId: agency?.sales?.id ?? '',
});
const save = useMutation(
trpc.bo.partners.saveAgency.mutationOptions({
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: trpc.bo.partners.agencies.queryKey() });
onDone?.();
onClose();
if (!agency) navigate(`/agencies/${r.id}`);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={agency ? t('ag.editTitle') : t('ag.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() || !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 })}
>
{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('crm.agencyCode')} hint={t('crm.agencyCodeHint')}>
<input className="bo-input" value={f.code} maxLength={32} onChange={(e) => setF({ ...f, code: e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '') })} />
</Field>
</div>
<div className="bo-row2">
<Field label={t('ag.pctLabel')}>
<input className="bo-input" inputMode="numeric" value={f.commissionPct} onChange={(e) => setF({ ...f, commissionPct: e.target.value.replace(/\D/g, '') })} />
</Field>
<Field label={t('ag.sales')}>
<select className="bo-select" value={f.salesId} onChange={(e) => setF({ ...f, salesId: e.target.value })}>
<option value="">—</option>
{team.data?.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</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>
<Field label={t('crm.note')}>
<textarea className="bo-textarea" style={{ minHeight: 64 }} 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('ag.active')}
</label>
</Dialog>
);
}
function AgencyPayout({ 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.agencyPayout.mutationOptions({
onSuccess: () => {
onDone();
toast(t('prv.payoutDone'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('ag.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>
);
}
+791
View File
@@ -0,0 +1,791 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useMemo, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Dialog, Field, I, Skeleton } from '../components/kit.tsx';
import { MapView, STAGE_COLORS, STAGE_COLOR_KEYS, type MapPoint } from '../components/map.tsx';
type Tab = 'board' | 'tasks' | 'map';
type Kind = 'VENUE' | 'AGENCY';
export const VENUE_CATEGORIES = ['cafe', 'restaurant', 'shop', 'beauty', 'other'] as const;
const DAY = 86_400_000;
const monthKey = (d = new Date()) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 7);
export function Crm({ me }: { me: Me }) {
const { t } = useT();
const trpc = useTRPC();
const { query } = useLocation();
const tab = (query.get('tab') as Tab) || 'board';
const isSuper = me.roles.includes('SUPERADMIN');
const [kind, setKind] = useState<Kind | undefined>();
const [ownerId, setOwnerId] = useState<string | undefined>();
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [dlg, setDlg] = useState<'new' | 'stages' | 'bonus' | null>(null);
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const team = useQuery(trpc.bo.crm.team.queryOptions());
const summary = useQuery({ ...trpc.bo.crm.summary.queryOptions({ ownerId }), refetchInterval: 60_000 });
return (
<>
<div className="bo-head">
<div>
<h1 className="bo-title">{t('crm.title')}</h1>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<BonusChip me={me} salesId={ownerId} onEdit={isSuper ? () => setDlg('bonus') : undefined} configured={summary.data?.bonusConfigured} />
{isSuper && (
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setDlg('stages')}>
{t('crm.stagesBtn')}
</button>
)}
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setDlg('new')}>
<I name="plus" size={16} />
{t('crm.newLead')}
</button>
</div>
</div>
<div className="bo-ogroups" role="tablist">
{(['board', 'tasks', 'map'] as const).map((x) => (
<button key={x} type="button" role="tab" className="bo-ogroup" aria-selected={tab === x} onClick={() => navigate(x === 'board' ? '/crm' : `/crm?tab=${x}`, { replace: true })}>
{t(`crm.tab.${x}`)}
{x === 'tasks' && !!summary.data?.overdue && <span className="bo-chat__dot">{summary.data.overdue}</span>}
</button>
))}
</div>
{tab === 'board' && (
<div className="bo-toolbar">
<label className="bo-search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} placeholder={t('crm.search')} aria-label={t('crm.search')} onChange={(e) => setSearch(e.target.value)} />
</label>
<div className="bo-pills" role="group">
{([undefined, 'VENUE', 'AGENCY'] as const).map((k) => (
<button key={k ?? 'all'} type="button" className="bo-pill" aria-pressed={kind === k} onClick={() => setKind(k)}>
{k ? t(`crm.kind.${k}`) : t('crm.kind.all')}
</button>
))}
</div>
{isSuper && (team.data?.length ?? 0) > 1 && (
<select className="bo-select" style={{ width: 'auto', minHeight: 36 }} value={ownerId ?? ''} onChange={(e) => setOwnerId(e.target.value || undefined)} aria-label={t('crm.owner')}>
<option value="">{t('crm.allSales')}</option>
{team.data!.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
<option value="none">{t('crm.noOwner')}</option>
</select>
)}
</div>
)}
{tab === 'board' && <Board kind={kind} ownerId={ownerId} search={q} showOwner={isSuper} counts={summary.data?.byStage} />}
{tab === 'tasks' && <Tasks me={me} />}
{tab === 'map' && <CrmMap />}
{dlg === 'new' && <LeadDialog me={me} onClose={() => setDlg(null)} />}
{dlg === 'stages' && <StagesDialog onClose={() => setDlg(null)} />}
{dlg === 'bonus' && <BonusDialog onClose={() => setDlg(null)} />}
</>
);
}
// ───── воронка ─────
function Board({ kind, ownerId, search, showOwner, counts }: { kind?: Kind; ownerId?: string; search: string; showOwner: boolean; counts?: Record<string, number> }) {
const { t, dateTime } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const stages = useQuery(trpc.bo.crm.stages.queryOptions());
const board = useQuery({ ...trpc.bo.crm.board.queryOptions({ kind, ownerId, search: search || undefined }), placeholderData: keepPreviousData });
const [drag, setDrag] = useState<string | null>(null);
const [over, setOver] = useState<string | null>(null);
const [pending, setPending] = useState<{ leadId: string; stage: { id: string; kind: string; name: string } } | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.crm.board.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
};
const move = useMutation(trpc.bo.crm.move.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
if (!stages.data || !board.data)
return (
<div className="bo-crm">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} h={300} />
))}
</div>
);
const drop = (stageId: string) => {
const lead = board.data.find((l) => l.id === drag);
const stage = stages.data.find((s) => s.id === stageId);
setDrag(null);
setOver(null);
if (!lead || !stage || lead.stageId === stageId) return;
// «Подключён» и «Отказ» требуют подробностей — открываем диалог
if (stage.kind === 'WON' && !lead.converted) return setPending({ leadId: lead.id, stage });
if (stage.kind === 'LOST') return setPending({ leadId: lead.id, stage });
move.mutate({ id: lead.id, stageId });
};
return (
<>
<div className="bo-crm">
{stages.data.map((s) => {
const leads = board.data.filter((l) => l.stageId === s.id);
return (
<section
key={s.id}
className="bo-crm__col"
data-over={over === s.id || undefined}
style={{ ['--stage' as string]: STAGE_COLORS[s.color] ?? STAGE_COLORS.blue }}
onDragOver={(e) => {
if (!drag) return;
e.preventDefault();
setOver(s.id);
}}
onDragLeave={() => setOver((o) => (o === s.id ? null : o))}
onDrop={() => drop(s.id)}
>
<header className="bo-crm__head">
<b>{s.name}</b>
<span>{counts?.[s.id] ?? leads.length}</span>
</header>
<div className="bo-crm__cards">
{leads.map((l) => {
const days = Math.floor((Date.now() - l.stageChangedAt.getTime()) / DAY);
return (
<a
key={l.id}
href={`#/crm/${l.id}`}
className="bo-lead"
draggable
onDragStart={(e) => {
setDrag(l.id);
e.dataTransfer.effectAllowed = 'move';
}}
onDragEnd={() => setDrag(null)}
data-dragging={drag === l.id || undefined}
>
<span className="bo-lead__top">
<span className={`bo-chip bo-chip--${l.kind === 'AGENCY' ? 'SUPPORT' : 'provider'}`}>{t(`crm.kind1.${l.kind}`)}</span>
{l.converted && <span className="bo-chip bo-chip--ok">{t('crm.connected')}</span>}
<span className="bo-grow" />
<span className="bo-faint bo-small" title={t('crm.inStage')}>
{t('crm.days', { n: days })}
</span>
</span>
<b className="bo-lead__name">{l.name}</b>
{(l.contactName || l.phone) && <span className="bo-small bo-muted">{[l.contactName, l.phone].filter(Boolean).join(' · ')}</span>}
{l.nextTask && (
<span className={`bo-lead__task${l.nextTask.overdue ? ' is-overdue' : ''}`}>
<I name="check" size={13} />
{l.nextTask.title} · {dateTime(l.nextTask.dueAt)}
</span>
)}
{showOwner && <span className="bo-small bo-faint">{l.owner?.name ?? t('crm.noOwner')}</span>}
</a>
);
})}
{leads.length === 0 && <p className="bo-crm__empty">{t('crm.dropHere')}</p>}
</div>
</section>
);
})}
</div>
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('crm.dragHint')}
</p>
{pending && pending.stage.kind === 'WON' && <ConvertDialog leadId={pending.leadId} canOpen={showOwner} onClose={() => setPending(null)} onDone={refresh} />}
{pending && pending.stage.kind === 'LOST' && <LostDialog leadId={pending.leadId} stageId={pending.stage.id} onClose={() => setPending(null)} onDone={refresh} />}
</>
);
}
// ───── задачи ─────
function Tasks({ me }: { me: Me }) {
const { t, dateTime } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [done, setDone] = useState(false);
const tasks = useQuery(trpc.bo.crm.tasks.queryOptions({ done }));
const [adding, setAdding] = useState(false);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.crm.tasks.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.board.queryKey() });
};
const toggle = useMutation(trpc.bo.crm.setTaskDone.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
const isSuper = me.roles.includes('SUPERADMIN');
const now = Date.now();
return (
<section className="bo-panel">
<div className="bo-panel__head">
<div className="bo-pills">
<button type="button" className="bo-pill" aria-pressed={!done} onClick={() => setDone(false)}>
{t('crm.tasksOpen')}
</button>
<button type="button" className="bo-pill" aria-pressed={done} onClick={() => setDone(true)}>
{t('crm.tasksDone')}
</button>
</div>
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setAdding(true)}>
<I name="plus" size={16} />
{t('crm.addTask')}
</button>
</div>
{!tasks.data ? (
<Skeleton h={160} />
) : tasks.data.length === 0 ? (
<p className="bo-empty">{done ? t('crm.noDoneTasks') : t('crm.noTasks')}</p>
) : (
<ul className="bo-tasks">
{tasks.data.map((x) => {
const overdue = !x.doneAt && x.dueAt.getTime() < now;
return (
<li key={x.id} data-overdue={overdue || undefined} data-done={!!x.doneAt || undefined}>
<label className="bo-tasks__check">
<input type="checkbox" checked={!!x.doneAt} onChange={(e) => toggle.mutate({ id: x.id, done: e.target.checked })} aria-label={x.title} />
</label>
<span className="bo-grow" style={{ minWidth: 0 }}>
<b>{x.title}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{x.lead ? (
<a href={`#/crm/${x.lead.id}`}>{x.lead.name}</a>
) : (
t('crm.noLead')
)}
{isSuper && ` · ${x.assignee.name}`}
</span>
</span>
<span className="bo-tasks__due">{overdue ? `${t('crm.overdue')} · ` : ''}{dateTime(x.dueAt)}</span>
{x.lead?.phone && (
<a className="bo-iconbtn" href={`tel:${x.lead.phone.replace(/[^\d+]/g, '')}`} aria-label={t('ord.call')} title={t('ord.call')}>
<I name="phone" size={18} />
</a>
)}
</li>
);
})}
</ul>
)}
<p className="bo-hint" style={{ marginTop: 12 }}>
{t('crm.tasksHint')}
</p>
{adding && <TaskDialog onClose={() => setAdding(false)} onDone={refresh} />}
</section>
);
}
export function TaskDialog({ leadId, onClose, onDone }: { leadId?: string; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const tomorrow = new Date(Date.now() + DAY + 4 * 3600_000).toISOString().slice(0, 10);
const [title, setTitle] = useState('');
const [day, setDay] = useState(tomorrow);
const [time, setTime] = useState('11:00');
const add = useMutation(
trpc.bo.crm.addTask.mutationOptions({
onSuccess: () => {
onDone();
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const presets = [t('crm.tp.call'), t('crm.tp.meet'), t('crm.tp.send'), t('crm.tp.check')];
return (
<Dialog
open
onClose={onClose}
title={t('crm.addTask')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!title.trim() || add.isPending} onClick={() => add.mutate({ leadId, title, dueAt: new Date(`${day}T${time}:00+04:00`) })}>
{t('ui.save')}
</button>
</>
}
>
<Field label={t('crm.taskTitle')}>
<input className="bo-input" autoFocus value={title} maxLength={200} onChange={(e) => setTitle(e.target.value)} />
</Field>
<div className="bo-pills">
{presets.map((p) => (
<button key={p} type="button" className="bo-pill" onClick={() => setTitle(p)}>
{p}
</button>
))}
</div>
<div className="bo-row2">
<Field label={t('crm.taskDay')}>
<input className="bo-input" type="date" value={day} onChange={(e) => setDay(e.target.value)} />
</Field>
<Field label={t('crm.taskTime')}>
<input className="bo-input" type="time" value={time} step={900} onChange={(e) => setTime(e.target.value)} />
</Field>
</div>
<p className="bo-hint">{t('crm.taskHint')}</p>
</Dialog>
);
}
// ───── карта ─────
function CrmMap() {
const { t } = useT();
const trpc = useTRPC();
const data = useQuery(trpc.bo.crm.map.queryOptions());
const points = useMemo<MapPoint[]>(
() => [
...(data.data?.leads ?? []).map((l) => ({ id: `l:${l.id}`, lat: l.lat, lng: l.lng, title: l.name, sub: l.stage, color: STAGE_COLORS[l.color] ?? STAGE_COLORS.blue!, dim: l.lost })),
...(data.data?.partners ?? []).map((p) => ({ id: `p:${p.id}`, lat: p.lat, lng: p.lng, title: p.name, sub: t('crm.partnerOnMap'), color: '#b8235a', strong: true, dim: !p.active })),
],
[data.data, t],
);
return (
<section className="bo-panel" style={{ padding: 10 }}>
<MapView points={points} height={560} onPick={(id) => navigate(id.startsWith('l:') ? `/crm/${id.slice(2)}` : `/venues/${id.slice(2)}`)} />
<div className="bo-maplegend">
<span>
<i style={{ background: '#b8235a', borderColor: '#4a1028' }} /> {t('crm.legendPartner')}
</span>
<span>
<i style={{ background: STAGE_COLORS.blue }} /> {t('crm.legendLead')}
</span>
<span className="bo-faint">{t('crm.mapHint')}</span>
</div>
</section>
);
}
// ───── диалоги ─────
export function LeadDialog({ me, lead, onClose }: { me: Me; lead?: { id: string; kind: Kind; name: string; category: string | null; contactName: string | null; phone: string | null; telegram: string | null; address: string | null; note: string | null; owner: { id: string } | null }; onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const team = useQuery(trpc.bo.crm.team.queryOptions());
const isSuper = me.roles.includes('SUPERADMIN');
const [f, setF] = useState({
kind: (lead?.kind ?? 'VENUE') as Kind,
name: lead?.name ?? '',
category: lead?.category ?? 'cafe',
contactName: lead?.contactName ?? '',
phone: lead?.phone ?? '',
telegram: lead?.telegram ?? '',
address: lead?.address ?? '',
note: lead?.note ?? '',
ownerId: lead?.owner?.id ?? me.id,
});
const done = (r?: { id: string } | void) => {
qc.invalidateQueries({ queryKey: trpc.bo.crm.board.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.get.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
onClose();
if (r && 'id' in r) navigate(`/crm/${r.id}`);
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const create = useMutation(trpc.bo.crm.create.mutationOptions({ onSuccess: done, onError }));
const update = useMutation(trpc.bo.crm.update.mutationOptions({ onSuccess: () => done(), onError }));
const payload = {
name: f.name,
category: f.kind === 'VENUE' ? (f.category as (typeof VENUE_CATEGORIES)[number]) : null,
contactName: f.contactName || null,
phone: f.phone || null,
telegram: f.telegram || null,
address: f.address || null,
note: f.note || null,
...(isSuper ? { ownerId: f.ownerId || null } : {}),
};
return (
<Dialog
open
wide
onClose={onClose}
title={lead ? t('crm.editLead') : t('crm.newLead')}
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() || create.isPending || update.isPending} onClick={() => (lead ? update.mutate({ id: lead.id, ...payload }) : create.mutate({ kind: f.kind, ...payload }))}>
{t('ui.save')}
</button>
</>
}
>
{!lead && (
<div className="bo-chtabs">
{(['VENUE', 'AGENCY'] as const).map((k) => (
<button key={k} type="button" aria-pressed={f.kind === k} onClick={() => setF({ ...f, kind: k })}>
{t(`crm.kind1.${k}`)}
</button>
))}
</div>
)}
<div className="bo-row2">
<Field label={t('crm.name')}>
<input className="bo-input" autoFocus value={f.name} maxLength={120} onChange={(e) => setF({ ...f, name: e.target.value })} placeholder={f.kind === 'VENUE' ? t('crm.namePhVenue') : t('crm.namePhAgency')} />
</Field>
{f.kind === 'VENUE' ? (
<Field label={t('crm.category')}>
<select className="bo-select" value={f.category} onChange={(e) => setF({ ...f, category: e.target.value })}>
{VENUE_CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`venue.cat.${c}`)}
</option>
))}
</select>
</Field>
) : (
<span />
)}
</div>
<div className="bo-row2">
<Field label={t('crm.contactName')}>
<input className="bo-input" value={f.contactName} maxLength={120} onChange={(e) => setF({ ...f, contactName: e.target.value })} placeholder={t('crm.contactPh')} />
</Field>
<Field label={t('chat.phone')}>
<input className="bo-input" value={f.phone} maxLength={40} onChange={(e) => setF({ ...f, phone: e.target.value })} placeholder="+374 …" />
</Field>
</div>
<div className="bo-row2">
<Field label="Telegram">
<input className="bo-input" value={f.telegram} maxLength={80} onChange={(e) => setF({ ...f, telegram: e.target.value })} placeholder="@username" />
</Field>
<Field label={t('crm.address')}>
<input className="bo-input" value={f.address} maxLength={200} onChange={(e) => setF({ ...f, address: e.target.value })} />
</Field>
</div>
{isSuper && (
<Field label={t('crm.owner')}>
<select className="bo-select" value={f.ownerId} onChange={(e) => setF({ ...f, ownerId: e.target.value })}>
<option value="">{t('crm.noOwner')}</option>
{team.data?.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</Field>
)}
<Field label={t('crm.note')}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={f.note} maxLength={4000} onChange={(e) => setF({ ...f, note: e.target.value })} />
</Field>
</Dialog>
);
}
/** «Подключён»: создаём партнёра/агентство и показываем ссылку, которую нужно отправить */
export function ConvertDialog({ leadId, canOpen, onClose, onDone }: { leadId: string; canOpen: boolean; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const lead = useQuery(trpc.bo.crm.get.queryOptions({ id: leadId }));
const [code, setCode] = useState('');
const [result, setResult] = useState<{ kind: 'VENUE' | 'AGENCY'; url: string; id: string } | null>(null);
const convert = useMutation(
trpc.bo.crm.convert.mutationOptions({
onSuccess: (r) => {
onDone();
setResult({ kind: r.kind, url: r.url, id: r.kind === 'VENUE' ? r.partnerId : r.agencyId });
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const isAgency = lead.data?.kind === 'AGENCY';
useEffect(() => {
if (lead.data && !code) setCode(lead.data.name.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 12));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lead.data]);
return (
<Dialog
open
onClose={onClose}
title={result ? t('crm.convertedTitle') : t('crm.convertTitle', { name: lead.data?.name ?? '' })}
footer={
result ? (
<>
{canOpen && (
<a className="bo-btn bo-btn--ghost" href={result.kind === 'VENUE' ? `#/venues/${result.id}` : `#/agencies/${result.id}`} onClick={onClose}>
{result.kind === 'VENUE' ? t('crm.openVenue') : t('crm.openAgency')}
</a>
)}
<button type="button" className="bo-btn" onClick={onClose}>
{t('ui.close')}
</button>
</>
) : (
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn bo-btn--accent" disabled={!lead.data || (isAgency && !code.trim()) || convert.isPending} onClick={() => convert.mutate({ id: leadId, agencyCode: isAgency ? code : undefined })}>
{t('crm.convert')}
</button>
</>
)
}
>
{result ? (
<>
<p style={{ margin: 0 }}>{result.kind === 'VENUE' ? t('crm.convertedVenue') : t('crm.convertedAgency')}</p>
<div className="bo-linkbox">
<code>{result.url}</code>
<button
type="button"
className="bo-btn bo-btn--sm"
onClick={() => {
void navigator.clipboard?.writeText(result.url);
toast(t('ui.copied'));
}}
>
{t('ui.copy')}
</button>
</div>
</>
) : isAgency ? (
<>
<p className="bo-hint">{t('crm.convertAgencyHint')}</p>
<Field label={t('crm.agencyCode')} hint={t('crm.agencyCodeHint')}>
<input className="bo-input" value={code} maxLength={32} onChange={(e) => setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))} />
</Field>
</>
) : (
<p className="bo-hint">{t('crm.convertVenueHint')}</p>
)}
</Dialog>
);
}
export function LostDialog({ leadId, stageId, onClose, onDone }: { leadId: string; stageId: string; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [reason, setReason] = useState('');
const move = useMutation(
trpc.bo.crm.move.mutationOptions({
onSuccess: () => {
onDone();
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const presets = [t('crm.lr.price'), t('crm.lr.notNow'), t('crm.lr.noAnswer'), t('crm.lr.competitor')];
return (
<Dialog
open
onClose={onClose}
title={t('crm.lostTitle')}
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() || move.isPending} onClick={() => move.mutate({ id: leadId, stageId, lostReason: reason })}>
{t('ui.save')}
</button>
</>
}
>
<Field label={t('crm.lostReason')}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
</Field>
<div className="bo-pills">
{presets.map((p) => (
<button key={p} type="button" className="bo-pill" onClick={() => setReason(p)}>
{p}
</button>
))}
</div>
</Dialog>
);
}
function StagesDialog({ onClose }: { onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const stages = useQuery(trpc.bo.crm.stages.queryOptions());
const [list, setList] = useState<{ id?: string; name: string; kind: 'OPEN' | 'WON' | 'LOST'; color: string }[] | null>(null);
useEffect(() => {
if (stages.data && !list) setList(stages.data.map((s) => ({ id: s.id, name: s.name, kind: s.kind, color: s.color })));
}, [stages.data, list]);
const save = useMutation(
trpc.bo.crm.saveStages.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.crm.stages.queryKey() });
toast(t('prv.saved'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const swap = (i: number, j: number) => {
if (!list || j < 0 || j >= list.length) return;
const next = [...list];
[next[i], next[j]] = [next[j]!, next[i]!];
setList(next);
};
return (
<Dialog
open
wide
onClose={onClose}
title={t('crm.stagesTitle')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => list && setList([...list, { name: '', kind: 'OPEN', color: 'blue' }])}>
<I name="plus" size={16} />
{t('crm.addStage')}
</button>
<span className="bo-grow" />
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!list || list.some((s) => !s.name.trim()) || save.isPending} onClick={() => list && save.mutate(list)}>
{t('ui.save')}
</button>
</>
}
>
<p className="bo-hint">{t('crm.stagesHint')}</p>
<ul className="bo-stages">
{(list ?? []).map((s, i) => (
<li key={s.id ?? `new-${i}`}>
<span className="bo-stages__arrows">
<button type="button" onClick={() => swap(i, i - 1)} aria-label="↑">
<I name="arrowUp" size={14} />
</button>
<button type="button" onClick={() => swap(i, i + 1)} aria-label="↓">
<I name="arrowDown" size={14} />
</button>
</span>
<input className="bo-input" value={s.name} maxLength={40} onChange={(e) => setList(list!.map((x, k) => (k === i ? { ...x, name: e.target.value } : x)))} />
<select className="bo-select" value={s.kind} onChange={(e) => setList(list!.map((x, k) => (k === i ? { ...x, kind: e.target.value as 'OPEN' } : x)))}>
{(['OPEN', 'WON', 'LOST'] as const).map((k) => (
<option key={k} value={k}>
{t(`crm.stageKind.${k}`)}
</option>
))}
</select>
<span className="bo-swatches">
{STAGE_COLOR_KEYS.map((c) => (
<button key={c} type="button" aria-pressed={s.color === c} style={{ background: STAGE_COLORS[c] }} onClick={() => setList(list!.map((x, k) => (k === i ? { ...x, color: c } : x)))} aria-label={c} />
))}
</span>
<button type="button" className="bo-iconbtn" onClick={() => setList(list!.filter((_, k) => k !== i))} aria-label={t('chat.tplDelete')}>
<I name="close" size={16} />
</button>
</li>
))}
</ul>
</Dialog>
);
}
// ───── бонус ─────
function BonusChip({ me, salesId, onEdit, configured }: { me: Me; salesId?: string; onEdit?: () => void; configured?: boolean }) {
const { t, amd } = useT();
const trpc = useTRPC();
const isSuper = me.roles.includes('SUPERADMIN');
const who = isSuper ? salesId : undefined;
const bonus = useQuery({ ...trpc.bo.crm.bonus.queryOptions({ month: monthKey(), salesId: who === 'none' ? undefined : who }) });
if (!bonus.data) return null;
const b = bonus.data;
const title = [t('crm.bonusVenues', { n: b.venues, sum: amd(b.fromVenues) }), t('crm.bonusAgencies', { n: b.agencies, sum: amd(b.fromAgencies) }), t('crm.bonusRevenue', { pct: b.rules.pct, sum: amd(b.fromRevenue) })].join('\n');
return (
<button type="button" className="bo-bonus" title={title} onClick={onEdit} disabled={!onEdit}>
<span className="bo-small">{isSuper && !salesId ? t('crm.bonusMineSuper') : t('crm.bonusMonth')}</span>
<b className="bo-num">{configured === false ? t('crm.bonusNotSet') : amd(b.total)}</b>
</button>
);
}
function BonusDialog({ onClose }: { onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const rules = useQuery(trpc.bo.crm.bonusRules.queryOptions());
const [f, setF] = useState<{ perVenue: string; perAgency: string; pct: string; months: string } | null>(null);
useEffect(() => {
if (rules.data && !f) setF({ perVenue: String(rules.data.perVenue), perAgency: String(rules.data.perAgency), pct: String(rules.data.pct), months: String(rules.data.months) });
}, [rules.data, f]);
const save = useMutation(
trpc.bo.crm.setBonusRules.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.crm.bonus.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.bonusRules.queryKey() });
toast(t('prv.saved'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const num = (v: string) => v.replace(/\D/g, '');
return (
<Dialog
open
onClose={onClose}
title={t('crm.bonusTitle')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn"
disabled={!f || save.isPending}
onClick={() => f && save.mutate({ perVenue: Number(f.perVenue) || 0, perAgency: Number(f.perAgency) || 0, pct: Math.min(100, Number(f.pct) || 0), months: Math.max(1, Number(f.months) || 1) })}
>
{t('ui.save')}
</button>
</>
}
>
{f && (
<>
<p className="bo-hint">{t('crm.bonusHint')}</p>
<div className="bo-row2">
<Field label={t('crm.bonusPerVenue')}>
<input className="bo-input" inputMode="numeric" value={f.perVenue} onChange={(e) => setF({ ...f, perVenue: num(e.target.value) })} />
</Field>
<Field label={t('crm.bonusPerAgency')}>
<input className="bo-input" inputMode="numeric" value={f.perAgency} onChange={(e) => setF({ ...f, perAgency: num(e.target.value) })} />
</Field>
</div>
<div className="bo-row2">
<Field label={t('crm.bonusPct')}>
<input className="bo-input" inputMode="numeric" value={f.pct} onChange={(e) => setF({ ...f, pct: num(e.target.value) })} />
</Field>
<Field label={t('crm.bonusMonths')}>
<input className="bo-input" inputMode="numeric" value={f.months} onChange={(e) => setF({ ...f, months: num(e.target.value) })} />
</Field>
</div>
</>
)}
</Dialog>
);
}
+354
View File
@@ -0,0 +1,354 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useRef, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, I, Skeleton } from '../components/kit.tsx';
import { MapView, parseCoords, STAGE_COLORS } from '../components/map.tsx';
import { ConvertDialog, LeadDialog, LostDialog, TaskDialog } from './Crm.tsx';
import { CopyLink } from './Agencies.tsx';
type ActType = 'CALL' | 'MEETING' | 'NOTE';
const ACT_ICON: Record<string, 'phone' | 'clients' | 'journal' | 'file' | 'swap' | 'check'> = { CALL: 'phone', MEETING: 'clients', NOTE: 'journal', FILE: 'file', STAGE: 'swap', WON: 'check' };
export function LeadCard({ id, me }: { id: string; me: Me }) {
const { t, date, dateTime } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const lead = useQuery(trpc.bo.crm.get.queryOptions({ id }));
const stages = useQuery(trpc.bo.crm.stages.queryOptions());
const [dlg, setDlg] = useState<'edit' | 'task' | 'convert' | { lost: string } | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.crm.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.crm.board.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.tasks.queryKey() });
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const move = useMutation(trpc.bo.crm.move.mutationOptions({ onSuccess: refresh, onError }));
const toggleTask = useMutation(trpc.bo.crm.setTaskDone.mutationOptions({ onSuccess: refresh, onError }));
const update = useMutation(trpc.bo.crm.update.mutationOptions({ onSuccess: refresh, onError }));
const remove = useMutation(trpc.bo.crm.remove.mutationOptions({ onSuccess: () => navigate('/crm'), onError }));
const [coords, setCoords] = useState('');
const isSuper = me.roles.includes('SUPERADMIN');
const back = (
<a className="bo-back" href="#/crm">
<I name="back" size={16} />
{t('crm.back')}
</a>
);
if (lead.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, lead.error)}</div>
</>
);
if (!lead.data || !stages.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={300} />
</div>
);
const d = lead.data;
const converted = !!(d.partner || d.agency);
const tel = d.phone?.replace(/[^\d+]/g, '');
const tgName = d.telegram?.replace(/^@/, '').replace(/^https?:\/\/t\.me\//, '');
const now = Date.now();
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', alignItems: 'center' }}>
<span className={`bo-chip bo-chip--${d.kind === 'AGENCY' ? 'SUPPORT' : 'provider'}`}>{t(`crm.kind1.${d.kind}`)}</span>
{d.category && <span>{t(`venue.cat.${d.category}`)}</span>}
<span>{d.owner ? t('crm.ownerIs', { name: d.owner.name }) : t('crm.noOwner')}</span>
<span>{t('crm.created', { date: date(d.createdAt) })}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{tel && (
<a className="bo-btn" href={`tel:${tel}`}>
<I name="phone" size={16} />
{t('ord.call')}
</a>
)}
{tgName && (
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${tgName}`} target="_blank" rel="noreferrer">
<I name="telegram" size={16} />
Telegram
</a>
)}
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setDlg('edit')}>
{t('prv.edit')}
</button>
</div>
</header>
{/* этапы: клик — перевести */}
<ol className="bo-steps bo-steps--click" aria-label={t('crm.stage')}>
{stages.data.map((s) => {
const cur = s.id === d.stageId;
return (
<li key={s.id} data-state={cur ? 'now' : 'next'} style={cur ? { borderTopColor: STAGE_COLORS[s.color], background: STAGE_COLORS[s.color] } : undefined}>
<button
type="button"
disabled={cur || move.isPending}
onClick={() => {
if (s.kind === 'WON' && !converted) return setDlg('convert');
if (s.kind === 'LOST') return setDlg({ lost: s.id });
move.mutate({ id, stageId: s.id });
}}
>
{s.name}
</button>
</li>
);
})}
</ol>
{d.lostReason && <div className="bo-banner" style={{ background: 'var(--bc-pink)', color: 'var(--bc-plum)' }}>{t('crm.lostBanner', { reason: d.lostReason })}</div>}
<div className="bo-client-grid">
<div className="bo-col">
<Composer leadId={id} onDone={refresh} />
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('crm.history')}</h2>
</div>
{d.activities.length === 0 ? (
<p className="bo-hint">{t('crm.noHistory')}</p>
) : (
<ol className="bo-acts">
{d.activities.map((a) => (
<li key={a.id} data-type={a.type}>
<span className="bo-acts__icon">
<I name={ACT_ICON[a.type] ?? 'journal'} size={16} />
</span>
<span className="bo-grow" style={{ minWidth: 0 }}>
<span className="bo-small bo-faint">
{t(`crm.act.${a.type}`)} · {a.author ?? '—'} · {dateTime(a.at)}
</span>
{a.text && <span className="bo-acts__text">{a.text}</span>}
{a.file &&
(a.file.mime?.startsWith('image/') ? (
<a href={`/files/bo/lead-file/${a.id}`} target="_blank" rel="noreferrer">
<img className="bo-acts__img" src={`/files/bo/lead-file/${a.id}`} alt={a.file.name ?? ''} loading="lazy" />
</a>
) : (
<a className="bo-msg__file" style={{ margin: '6px 0 0', background: 'var(--bo-soft)' }} href={`/files/bo/lead-file/${a.id}`} target="_blank" rel="noreferrer">
<I name="file" size={20} />
<span className="bo-msg__fname">{a.file.name}</span>
</a>
))}
</span>
</li>
))}
</ol>
)}
</section>
</div>
<div className="bo-col">
<section className={`bo-panel${converted ? ' bo-wonbox' : ''}`}>
<div className="bo-panel__head">
<h2 className="bo-h2">{converted ? t('crm.connected') : t('crm.connect')}</h2>
</div>
{d.partner ? (
<>
<p className="bo-hint">{d.partner.ownerLinked ? t('crm.ownerLinked') : t('crm.ownerNotYet')}</p>
{!d.partner.ownerLinked && d.partner.ownerInviteUrl && (
<div style={{ marginBottom: 10 }}>
<CopyLink url={d.partner.ownerInviteUrl} />
</div>
)}
{isSuper && (
<a className="bo-btn bo-btn--sm" href={`#/venues/${d.partner.id}`}>
{t('crm.openVenue')}
</a>
)}
</>
) : d.agency ? (
<>
<p className="bo-hint">{t('crm.agencyInfo', { code: d.agency.code, n: d.agency.clients })}</p>
{isSuper && (
<a className="bo-btn bo-btn--sm" href={`#/agencies/${d.agency.id}`}>
{t('crm.openAgency')}
</a>
)}
</>
) : (
<>
<p className="bo-hint">{d.kind === 'VENUE' ? t('crm.convertVenueHint') : t('crm.convertAgencyHint')}</p>
<button type="button" className="bo-btn bo-btn--accent bo-btn--sm" onClick={() => setDlg('convert')}>
<I name="check" size={16} />
{t('crm.convert')}
</button>
</>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('crm.tasks')}</h2>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg('task')}>
<I name="plus" size={16} />
{t('crm.addTask')}
</button>
</div>
{d.tasks.length === 0 ? (
<p className="bo-hint">{t('crm.noLeadTasks')}</p>
) : (
<ul className="bo-tasks">
{d.tasks.map((x) => {
const overdue = !x.doneAt && x.dueAt.getTime() < now;
return (
<li key={x.id} data-overdue={overdue || undefined} data-done={!!x.doneAt || undefined}>
<label className="bo-tasks__check">
<input type="checkbox" checked={!!x.doneAt} onChange={(e) => toggleTask.mutate({ id: x.id, done: e.target.checked })} aria-label={x.title} />
</label>
<span className="bo-grow">
<b>{x.title}</b>
</span>
<span className="bo-tasks__due">{dateTime(x.dueAt)}</span>
</li>
);
})}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('crm.contacts')}</h2>
</div>
<dl className="bo-kv">
<dt>{t('crm.contactName')}</dt>
<dd>{d.contactName ?? '—'}</dd>
<dt>{t('chat.phone')}</dt>
<dd>{d.phone ? <a href={`tel:${tel}`}>{d.phone}</a> : '—'}</dd>
<dt>Telegram</dt>
<dd>{tgName ? <a href={`https://t.me/${tgName}`} target="_blank" rel="noreferrer">@{tgName}</a> : '—'}</dd>
<dt>{t('crm.address')}</dt>
<dd>{d.address ?? '—'}</dd>
</dl>
{d.note && <p className="bo-side-panel__note" style={{ marginTop: 12 }}>{d.note}</p>}
{d.kind === 'VENUE' && (
<div style={{ marginTop: 14 }}>
{d.lat != null && d.lng != null ? (
<MapView points={[{ id: d.id, lat: d.lat, lng: d.lng, title: d.name, color: '#b8235a', strong: true }]} height={200} focus={[d.lat, d.lng]} />
) : (
<p className="bo-hint">{t('crm.noCoords')}</p>
)}
<form
style={{ display: 'flex', gap: 8, marginTop: 8 }}
onSubmit={(e) => {
e.preventDefault();
const c = parseCoords(coords);
if (!c) return toast(t('crm.coordsBad'), 'error');
update.mutate({ id, lat: c.lat, lng: c.lng });
setCoords('');
}}
>
<input className="bo-input" value={coords} onChange={(e) => setCoords(e.target.value)} placeholder={t('crm.coordsPh')} style={{ minHeight: 36, padding: '6px 12px' }} />
<button type="submit" className="bo-btn bo-btn--sm" disabled={!coords.trim()}>
{t('ui.save')}
</button>
</form>
</div>
)}
</section>
{!converted && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-danger)', alignSelf: 'flex-start' }} onClick={() => confirm(t('crm.removeConfirm')) && remove.mutate({ id })}>
{t('crm.remove')}
</button>
)}
</div>
</div>
{dlg === 'edit' && <LeadDialog me={me} lead={d} onClose={() => setDlg(null)} />}
{dlg === 'task' && <TaskDialog leadId={id} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'convert' && <ConvertDialog leadId={id} canOpen={isSuper} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg && typeof dlg === 'object' && <LostDialog leadId={id} stageId={dlg.lost} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
/** Записать контакт: звонок / встреча / заметка, можно с фото или файлом */
function Composer({ leadId, onDone }: { leadId: string; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [type, setType] = useState<ActType>('CALL');
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const file = useRef<HTMLInputElement>(null);
const add = useMutation(
trpc.bo.crm.addActivity.mutationOptions({
onSuccess: () => {
setText('');
onDone();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const upload = async (f: File) => {
if (f.size > 10 * 1024 * 1024) return toast(t('chat.fileTooBig'), 'error');
setBusy(true);
try {
const form = new FormData();
form.set('text', text);
form.set('file', f);
const res = await fetch(`/files/bo/lead/${leadId}`, { method: 'POST', body: form, credentials: 'include' });
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? String(res.status));
setText('');
onDone();
} catch (e) {
toast(errText(t, e), 'error');
} finally {
setBusy(false);
}
};
return (
<section className="bo-panel">
<div className="bo-composer__tabs" role="tablist">
{(['CALL', 'MEETING', 'NOTE'] as const).map((x) => (
<button key={x} type="button" role="tab" aria-selected={type === x} onClick={() => setType(x)}>
<I name={ACT_ICON[x]!} size={13} />
{t(`crm.act.${x}`)}
</button>
))}
</div>
<textarea className="bo-textarea" style={{ minHeight: 76 }} value={text} maxLength={4000} onChange={(e) => setText(e.target.value)} placeholder={t(`crm.actPh.${type}`)} />
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button type="button" className="bo-btn bo-btn--sm" disabled={!text.trim() || add.isPending} onClick={() => add.mutate({ id: leadId, type, text })}>
{t('crm.addAct')}
</button>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={busy} onClick={() => file.current?.click()}>
<I name="clip" size={16} />
{t('crm.attach')}
</button>
<input
ref={file}
type="file"
hidden
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void upload(f);
}}
/>
</div>
</section>
);
}
+482
View File
@@ -0,0 +1,482 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useMemo, useState } from 'react';
import { errText, useT, type BoLocale } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge, Skeleton } from '../components/kit.tsx';
import { MapView, parseCoords } from '../components/map.tsx';
import { CopyLink } from './Agencies.tsx';
import { VENUE_CATEGORIES } from './Crm.tsx';
type L5 = { ru: string; en: string; zh: string; hy: string; es: string };
const EMPTY: L5 = { ru: '', en: '', zh: '', hy: '', es: '' };
const pickLang = (v: Record<string, string> | null | undefined, l: BoLocale) => (v ? v[l] || v.ru || v.en || '' : '');
export function Venues() {
const { t, date } = useT();
const trpc = useTRPC();
const list = useQuery(trpc.bo.partners.venues.queryOptions());
const [creating, setCreating] = useState(false);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('vn.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('vn.add')}
</button>
</div>
{!list.data ? (
<Skeleton h={240} />
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('vn.empty')}</div>
) : (
<div className="bo-panel" style={{ padding: 8 }}>
<table className="bo-table bo-ptable">
<thead>
<tr>
<th>{t('vn.col.name')}</th>
<th className="bo-r">{t('vn.col.offers')}</th>
<th className="bo-r">{t('vn.col.staff')}</th>
<th className="bo-r">{t('vn.col.visits')}</th>
<th>{t('vn.col.last')}</th>
</tr>
</thead>
<tbody>
{list.data.map((v) => (
<tr key={v.id} onClick={() => navigate(`/venues/${v.id}`)} data-off={!v.active || undefined}>
<td>
<a href={`#/venues/${v.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
<Avatar name={v.name} id={v.id} size={38} />
<span style={{ minWidth: 0 }}>
<span className="bo-who__name">{v.name}</span>
<span className="bo-who__meta">{[t(`venue.cat.${v.category}`), v.address, v.sales?.name].filter(Boolean).join(' · ')}</span>
<span className="bo-tags" style={{ marginTop: 4 }}>
{!v.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
{v.members === 0 && <span className="bo-chip bo-chip--warn">{t('vn.noOwner')}</span>}
{v.offers === 0 && <span className="bo-chip">{t('vn.noOffers')}</span>}
</span>
</span>
</a>
</td>
<td className="bo-r bo-num">{v.offers}</td>
<td className="bo-r bo-num">{v.members}</td>
<td className="bo-r bo-num">{v.visits30}</td>
<td className="bo-small">{v.lastVisit ? date(v.lastVisit) : <span className="bo-faint">—</span>}</td>
</tr>
))}
</tbody>
</table>
<p className="bo-hint" style={{ padding: '10px 12px 4px' }}>
{t('vn.hint')}
</p>
</div>
)}
{creating && <VenueDialog onClose={() => setCreating(false)} />}
</>
);
}
export function VenueCard({ id }: { id: string }) {
const { t, locale, date, dateTime } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const v = useQuery(trpc.bo.partners.venue.queryOptions({ id }));
const [dlg, setDlg] = useState<'edit' | { offer?: NonNullable<typeof v.data>['offers'][number] } | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.partners.venue.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.partners.venues.queryKey() });
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const invite = useMutation(trpc.bo.partners.ownerInvite.mutationOptions({ onSuccess: refresh, onError }));
const removeMember = useMutation(trpc.bo.partners.removeMember.mutationOptions({ onSuccess: refresh, onError }));
const points = useMemo(() => (v.data?.lat != null && v.data.lng != null ? [{ id: v.data.id, lat: v.data.lat, lng: v.data.lng, title: v.data.name, color: '#b8235a', strong: true }] : []), [v.data]);
const back = (
<a className="bo-back" href="#/venues">
<I name="back" size={16} />
{t('vn.back')}
</a>
);
if (!v.data)
return (
<>
{back}
{v.isError ? <div className="bo-panel bo-empty">{errText(t, v.error)}</div> : <Skeleton h={300} />}
</>
);
const d = v.data;
const maxWeek = Math.max(1, ...d.weeks.map((w) => w.checks + w.redeemed));
const hasOwner = d.members.some((m) => m.role === 'OWNER');
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>{t(`venue.cat.${d.category}`)}</span>
{d.address && <span>{d.address}</span>}
{d.phone && <a href={`tel:${d.phone.replace(/[^\d+]/g, '')}`}>{d.phone}</a>}
{d.sales && <span>{t('ag.byS', { name: d.sales.name })}</span>}
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
</div>
{pickLang(d.offer, locale) && <p style={{ margin: '10px 0 0' }}>{pickLang(d.offer, locale)}</p>}
</div>
<button type="button" className="bo-btn" onClick={() => setDlg('edit')}>
{t('prv.edit')}
</button>
</header>
{!hasOwner && (
<div className="bo-banner">
<span>{t('vn.ownerHint')}</span>
{d.ownerInviteUrl ? (
<CopyLink url={d.ownerInviteUrl} />
) : (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => invite.mutate({ id })} disabled={invite.isPending}>
{t('vn.makeLink')}
</button>
)}
</div>
)}
<div className="bo-client-grid">
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('vn.offers')}</h2>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({})}>
<I name="plus" size={16} />
{t('vn.addOffer')}
</button>
</div>
{d.offers.length === 0 ? (
<p className="bo-hint">{t('vn.noOffersHint')}</p>
) : (
<ul className="bo-list">
{d.offers.map((o) => (
<li key={o.id} data-off={!o.active || undefined} style={{ opacity: o.active ? 1 : 0.5 }}>
<PlanBadge plan={o.minLevel} t={t} />
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
<b>{pickLang(o.title, locale)}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{o.minLevel ? t(`vn.level.${o.minLevel}`) : t('vn.level.any')} · {t(`vn.limit.${o.limit}`, { n: o.limitCount })}
{!o.active && ` · ${t('vn.offerOff')}`}
</span>
</span>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({ offer: o })}>
{t('prv.edit')}
</button>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('vn.activity')}</h2>
<span className="bo-faint bo-small">{t('vn.weeks8')}</span>
</div>
<div className="bo-weeks">
{d.weeks.map((w) => (
<div key={w.from.toISOString()} title={`${date(w.from)}: ${w.checks} / ${w.redeemed}`}>
<span className="bo-weeks__bar" style={{ height: `${((w.checks + w.redeemed) / maxWeek) * 100}%` }}>
<span style={{ height: `${w.checks + w.redeemed ? (w.redeemed / (w.checks + w.redeemed)) * 100 : 0}%` }} />
</span>
<span className="bo-small bo-faint">{w.checks + w.redeemed}</span>
</div>
))}
</div>
<div className="bo-chart__legend" style={{ marginTop: 8, color: 'var(--bc-ink-2)', justifyContent: 'flex-start', gap: 18 }}>
<span className="bo-key" style={{ ['--key' as string]: 'var(--bc-blue)' }}>{t('vn.checks')}</span>
<span className="bo-key" style={{ ['--key' as string]: 'var(--bc-accent)' }}>{t('vn.redeemed')}</span>
</div>
{d.recent.length > 0 && (
<ul className="bo-list" style={{ marginTop: 12 }}>
{d.recent.slice(0, 12).map((u) => (
<li key={u.id}>
<span className="bo-feed__time">{dateTime(u.at)}</span>
<a href={`#/clients/${u.clientId}`} className="bo-grow" style={{ color: 'inherit' }}>
{u.client}
<span className="bo-small bo-muted">
{' '}
· {u.offer ? pickLang(u.offer, locale) : t('vn.cardCheck')}
{u.by && ` · ${u.by}`}
</span>
</a>
</li>
))}
</ul>
)}
</section>
</div>
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('vn.staff')}</h2>
{hasOwner && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => invite.mutate({ id })} disabled={invite.isPending} title={t('vn.newOwnerLinkHint')}>
{t('vn.newOwnerLink')}
</button>
)}
</div>
{d.members.length === 0 ? (
<p className="bo-hint">{t('vn.noStaff')}</p>
) : (
<ul className="bo-list">
{d.members.map((m) => (
<li key={m.id}>
<span className="bo-grow">
<b>{m.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{t(`vn.role.${m.role}`)}
{m.username && ` · @${m.username}`} · {date(m.since)}
</span>
</span>
<button type="button" className="bo-iconbtn" onClick={() => confirm(t('vn.removeStaff', { name: m.name })) && removeMember.mutate({ id: m.id })} aria-label={t('chat.tplDelete')}>
<I name="close" size={16} />
</button>
</li>
))}
</ul>
)}
{hasOwner && d.ownerInviteUrl && (
<div style={{ marginTop: 10 }}>
<CopyLink url={d.ownerInviteUrl} />
</div>
)}
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('vn.staffHint')}
</p>
</section>
<section className="bo-panel" style={{ padding: 10 }}>
{points.length ? <MapView points={points} height={220} focus={[d.lat!, d.lng!]} /> : <p className="bo-hint" style={{ padding: 12 }}>{t('crm.noCoords')}</p>}
</section>
{d.note && <p className="bo-side-panel__note">{d.note}</p>}
</div>
</div>
{dlg === 'edit' && <VenueDialog venue={d} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg && typeof dlg === 'object' && <OfferDialog partnerId={id} offer={dlg.offer} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
function VenueDialog({ venue, onClose, onDone }: { venue?: { id: string; name: string; category: string; offer: Record<string, string>; address: string | null; phone: string | null; lat: number | null; lng: number | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const team = useQuery(trpc.bo.partners.salesTeam.queryOptions());
const [f, setF] = useState({
name: venue?.name ?? '',
category: venue?.category ?? 'cafe',
offer: { ...EMPTY, ...(venue?.offer ?? {}) } as L5,
address: venue?.address ?? '',
phone: venue?.phone ?? '',
coords: venue?.lat != null && venue.lng != null ? `${venue.lat}, ${venue.lng}` : '',
note: venue?.note ?? '',
active: venue?.active ?? true,
salesId: venue?.sales?.id ?? '',
});
const save = useMutation(
trpc.bo.partners.saveVenue.mutationOptions({
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: trpc.bo.partners.venues.queryKey() });
onDone?.();
onClose();
if (!venue) navigate(`/venues/${r.id}`);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const coords = f.coords.trim() ? parseCoords(f.coords) : null;
return (
<Dialog
open
wide
onClose={onClose}
title={venue ? t('vn.editTitle') : t('vn.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() || (!!f.coords.trim() && !coords) || save.isPending}
onClick={() =>
save.mutate({
id: venue?.id,
name: f.name,
category: f.category as (typeof VENUE_CATEGORIES)[number],
offer: f.offer,
address: f.address || null,
phone: f.phone || null,
lat: coords?.lat ?? null,
lng: coords?.lng ?? null,
note: f.note || null,
active: f.active,
salesId: f.salesId || null,
})
}
>
{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('crm.category')}>
<select className="bo-select" value={f.category} onChange={(e) => setF({ ...f, category: e.target.value })}>
{VENUE_CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`venue.cat.${c}`)}
</option>
))}
</select>
</Field>
</div>
<L5Fields label={t('vn.blurb')} hint={t('vn.blurbHint')} value={f.offer} onChange={(offer) => setF({ ...f, offer })} />
<div className="bo-row2">
<Field label={t('crm.address')}>
<input className="bo-input" value={f.address} maxLength={200} onChange={(e) => setF({ ...f, address: e.target.value })} />
</Field>
<Field label={t('chat.phone')}>
<input className="bo-input" value={f.phone} maxLength={40} onChange={(e) => setF({ ...f, phone: e.target.value })} />
</Field>
</div>
<div className="bo-row2">
<Field label={t('vn.coords')} hint={f.coords.trim() && !coords ? t('crm.coordsBad') : t('crm.coordsPh')}>
<input className="bo-input" value={f.coords} onChange={(e) => setF({ ...f, coords: e.target.value })} placeholder="40.1811, 44.5136" />
</Field>
<Field label={t('ag.sales')}>
<select className="bo-select" value={f.salesId} onChange={(e) => setF({ ...f, salesId: e.target.value })}>
<option value="">—</option>
{team.data?.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</Field>
</div>
<Field label={t('crm.note')}>
<textarea className="bo-textarea" style={{ minHeight: 60 }} 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('vn.activeLabel')}
</label>
</Dialog>
);
}
/** Текст на 5 языках: русский обязателен, остальные — по желанию (клиент увидит русский, если перевода нет) */
function L5Fields({ label, hint, value, onChange }: { label: string; hint?: string; value: L5; onChange: (v: L5) => void }) {
const [open, setOpen] = useState(!!(value.en || value.hy || value.zh || value.es));
return (
<div className="bo-field">
<span className="bo-label">{label} · RU</span>
<input className="bo-input" value={value.ru} maxLength={300} onChange={(e) => onChange({ ...value, ru: e.target.value })} />
{hint && <span className="bo-hint">{hint}</span>}
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => setOpen((o) => !o)}>
EN · HY · ZH · ES {open ? '▴' : '▾'}
</button>
{open && (
<div className="bo-row2">
{(['en', 'hy', 'zh', 'es'] as const).map((l) => (
<input key={l} className="bo-input" value={value[l]} maxLength={300} placeholder={l.toUpperCase()} onChange={(e) => onChange({ ...value, [l]: e.target.value })} />
))}
</div>
)}
</div>
);
}
function OfferDialog({ partnerId, offer, onClose, onDone }: { partnerId: string; offer?: { id: string; title: Record<string, string>; minLevel: 'BAREV_PLUS' | 'ELITE' | null; limit: 'NONE' | 'ONCE' | 'WEEK' | 'MONTH'; limitCount: number; active: boolean }; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [f, setF] = useState({
title: { ...EMPTY, ...(offer?.title ?? {}) } as L5,
minLevel: offer?.minLevel ?? null,
limit: offer?.limit ?? 'NONE',
limitCount: String(offer?.limitCount ?? 1),
active: offer?.active ?? true,
});
const save = useMutation(
trpc.bo.partners.saveOffer.mutationOptions({
onSuccess: () => {
onDone();
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={offer ? t('vn.editOffer') : t('vn.addOffer')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn"
disabled={!f.title.ru.trim() || save.isPending}
onClick={() => save.mutate({ id: offer?.id, partnerId, title: f.title, minLevel: f.minLevel, limit: f.limit, limitCount: Math.max(1, Number(f.limitCount) || 1), active: f.active })}
>
{t('ui.save')}
</button>
</>
}
>
<L5Fields label={t('vn.offerTitle')} hint={t('vn.offerTitleHint')} value={f.title} onChange={(title) => setF({ ...f, title })} />
<Field label={t('vn.forWhom')}>
<div className="bo-pills">
{([null, 'BAREV_PLUS', 'ELITE'] as const).map((l) => (
<button key={l ?? 'any'} type="button" className="bo-pill" aria-pressed={f.minLevel === l} onClick={() => setF({ ...f, minLevel: l })}>
{l ? t(`vn.level.${l}`) : t('vn.level.any')}
</button>
))}
</div>
</Field>
<div className="bo-row2">
<Field label={t('vn.howOften')}>
<select className="bo-select" value={f.limit} onChange={(e) => setF({ ...f, limit: e.target.value as typeof f.limit })}>
{(['NONE', 'ONCE', 'WEEK', 'MONTH'] as const).map((l) => (
<option key={l} value={l}>
{t(`vn.limitOpt.${l}`)}
</option>
))}
</select>
</Field>
{(f.limit === 'WEEK' || f.limit === 'MONTH') && (
<Field label={t('vn.times')}>
<input className="bo-input" inputMode="numeric" value={f.limitCount} onChange={(e) => setF({ ...f, limitCount: e.target.value.replace(/\D/g, '') })} />
</Field>
)}
</div>
<label className="bo-check">
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
{t('vn.offerActive')}
</label>
</Dialog>
);
}
+15 -1
View File
@@ -4,7 +4,7 @@
*/
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { autoAssign, chatRecipients, dueReminders, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { autoAssign, 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';
@@ -286,6 +286,18 @@ async function handle(event: DomainEvent) {
for (const a of recipients) await proBot.send({ chatId: a.telegramId!, text, buttons });
return;
}
case 'sales.taskDue': {
const task = await db.salesTask.findUnique({ where: { id: event.taskId }, include: { assignee: true, lead: true } });
if (!task || task.doneAt || !task.assignee.telegramId || !task.assignee.active) return;
const link =
BACKOFFICE_URL.startsWith('https://') && task.leadId ? [{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/crm/${task.leadId}` }] : [];
await proBot.send({
chatId: task.assignee.telegramId,
text: `⏰ <b>${escapeHtml(task.title)}</b>${task.lead ? `\n${escapeHtml(task.lead.name)}${task.lead.phone ? ` · ${escapeHtml(task.lead.phone)}` : ''}` : ''}`,
buttons: link.length ? [link] : [],
});
return;
}
case 'chat.assigned': {
const [thread, assignee, by] = await Promise.all([
db.chatThread.findUnique({ where: { id: event.threadId }, include: { client: { include: { user: true } }, provider: true } }),
@@ -367,6 +379,8 @@ 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 dueSalesTasks(ctx);
} catch (e) {
console.error('[worker] tick failed', e);
} finally {
+8
View File
@@ -45,6 +45,14 @@ export const settingsSchema = z.object({
'assign.auto': z.boolean().default(true),
/** сколько исполнителей подряд система пробует сама, дальше заказ ждёт диспетчера */
'assign.maxAutoOffers': z.number().int().min(1).max(20).default(5),
/** бонус продажника: за каждое подключённое заведение, ֏ (0 — не начисляется; задаёт владелец) */
'sales.bonusPerVenue': z.number().int().min(0).default(0),
/** бонус продажника: за каждое подключённое агентство, ֏ */
'sales.bonusPerAgency': z.number().int().min(0).default(0),
/** бонус продажника: % от выручки с клиентов его агентств (оплаченные заказы и подписки) */
'sales.bonusAgencyPct': z.number().int().min(0).max(100).default(0),
/** …сколько месяцев после подключения агентства этот % начисляется */
'sales.bonusAgencyMonths': z.number().int().min(1).max(60).default(6),
/** MSG-1: чат закрывается через N часов после завершения (Этап 2) */
'chat.closeAfterHours': z.number().int().min(0).default(24),
/** раздел 6: автоудаление сканов через N дней после завершения */
@@ -0,0 +1,140 @@
-- CreateEnum
CREATE TYPE "SalesLeadKind" AS ENUM ('VENUE', 'AGENCY');
-- CreateEnum
CREATE TYPE "SalesStageKind" AS ENUM ('OPEN', 'WON', 'LOST');
-- AlterTable
ALTER TABLE "Agency" ADD COLUMN "note" TEXT,
ADD COLUMN "salesId" TEXT;
-- AlterTable
ALTER TABLE "Partner" ADD COLUMN "lat" DOUBLE PRECISION,
ADD COLUMN "lng" DOUBLE PRECISION,
ADD COLUMN "note" TEXT,
ADD COLUMN "phone" TEXT,
ADD COLUMN "salesId" TEXT;
-- AlterTable
ALTER TABLE "Payout" ADD COLUMN "agencyId" TEXT;
-- CreateTable
CREATE TABLE "SalesStage" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kind" "SalesStageKind" NOT NULL DEFAULT 'OPEN',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"color" TEXT NOT NULL DEFAULT 'blue',
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SalesStage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalesLead" (
"id" TEXT NOT NULL,
"kind" "SalesLeadKind" NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"contactName" TEXT,
"phone" TEXT,
"telegram" TEXT,
"address" TEXT,
"lat" DOUBLE PRECISION,
"lng" DOUBLE PRECISION,
"stageId" TEXT NOT NULL,
"stageChangedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ownerId" TEXT,
"note" TEXT,
"lostReason" TEXT,
"partnerId" TEXT,
"agencyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SalesLead_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalesActivity" (
"id" TEXT NOT NULL,
"leadId" TEXT NOT NULL,
"authorId" TEXT,
"type" TEXT NOT NULL,
"text" TEXT,
"fileKey" TEXT,
"fileName" TEXT,
"mime" TEXT,
"size" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SalesActivity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalesTask" (
"id" TEXT NOT NULL,
"leadId" TEXT,
"assigneeId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"dueAt" TIMESTAMP(3) NOT NULL,
"doneAt" TIMESTAMP(3),
"remindedAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SalesTask_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SalesLead_ownerId_stageId_idx" ON "SalesLead"("ownerId", "stageId");
-- CreateIndex
CREATE INDEX "SalesActivity_leadId_createdAt_idx" ON "SalesActivity"("leadId", "createdAt");
-- CreateIndex
CREATE INDEX "SalesTask_assigneeId_doneAt_dueAt_idx" ON "SalesTask"("assigneeId", "doneAt", "dueAt");
-- CreateIndex
CREATE INDEX "Payout_agencyId_createdAt_idx" ON "Payout"("agencyId", "createdAt");
-- AddForeignKey
ALTER TABLE "Agency" ADD CONSTRAINT "Agency_salesId_fkey" FOREIGN KEY ("salesId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Partner" ADD CONSTRAINT "Partner_salesId_fkey" FOREIGN KEY ("salesId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_agencyId_fkey" FOREIGN KEY ("agencyId") REFERENCES "Agency"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesLead" ADD CONSTRAINT "SalesLead_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "SalesStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesLead" ADD CONSTRAINT "SalesLead_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesLead" ADD CONSTRAINT "SalesLead_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesLead" ADD CONSTRAINT "SalesLead_agencyId_fkey" FOREIGN KEY ("agencyId") REFERENCES "Agency"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesActivity" ADD CONSTRAINT "SalesActivity_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "SalesLead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesTask" ADD CONSTRAINT "SalesTask_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "SalesLead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesTask" ADD CONSTRAINT "SalesTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "AdminUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Воронка по умолчанию (по ТЗ): Новый → Контакт → Встреча → Переговоры → Подключён / Отказ; суперадмин меняет в CRM
INSERT INTO "SalesStage" ("id", "name", "kind", "sortOrder", "color") VALUES
('stg_new', 'Новый', 'OPEN', 0, 'blue'),
('stg_contact', 'Контакт', 'OPEN', 1, 'learn'),
('stg_meeting', 'Встреча', 'OPEN', 2, 'docs'),
('stg_talks', 'Переговоры', 'OPEN', 3, 'perks'),
('stg_won', 'Подключён', 'WON', 4, 'home'),
('stg_lost', 'Отказ', 'LOST', 5, 'pink');
+123
View File
@@ -664,12 +664,19 @@ model Agency {
commissionPct Int @default(10)
contact String?
active Boolean @default(true)
/// продажник, который подключил агентство (бонус в CRM)
salesId String?
sales AdminUser? @relation("AgencySales", fields: [salesId], references: [id])
/// заметка команды
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clients Client[]
commissions AgencyCommission[]
promoCodes PromoCode[]
payouts Payout[]
leads SalesLead[]
}
model AgencyCommission {
@@ -755,6 +762,14 @@ model Partner {
offer Json
terms Json?
address String?
/// точка на карте заведений
lat Float?
lng Float?
phone String?
/// продажник, который подключил заведение (бонус в CRM)
salesId String?
sales AdminUser? @relation("PartnerSales", fields: [salesId], references: [id])
note String?
active Boolean @default(true)
/// одноразовая ссылка для собственника: t.me/<рабочий бот>?start=po-<код> (выдаёт админка)
ownerInviteCode String? @unique
@@ -764,6 +779,7 @@ model Partner {
offers PartnerOffer[]
members PartnerMember[]
invites PartnerInvite[]
leads SalesLead[]
}
enum OfferLimit {
@@ -850,6 +866,9 @@ model Payout {
provider Provider? @relation(fields: [providerId], references: [id])
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
/// вознаграждение агентству
agencyId String?
agency Agency? @relation(fields: [agencyId], references: [id])
periodStart DateTime
periodEnd DateTime
amount Int
@@ -861,6 +880,7 @@ model Payout {
createdAt DateTime @default(now())
@@index([providerId, createdAt])
@@index([agencyId, createdAt])
}
// ───────────── Админка ─────────────
@@ -891,6 +911,10 @@ model AdminUser {
segments SavedSegment[]
chatThreads ChatThread[]
chatTemplates ChatTemplate[]
salesAgencies Agency[] @relation("AgencySales")
salesPartners Partner[] @relation("PartnerSales")
salesLeads SalesLead[] @relation("LeadOwner")
salesTasks SalesTask[] @relation("TaskAssignee")
}
/// Вход в новую админку через Telegram: страница показывает ссылку t.me/<рабочий бот>?start=lg-<code>,
@@ -1246,3 +1270,102 @@ model ChatTemplate {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ───────────── CRM продажника (этап 4) ─────────────
enum SalesLeadKind {
/// кафе, ресторан, магазин… — станет партнёром клубной карты
VENUE
/// агентство недвижимости / релокации — будет приводить клиентов
AGENCY
}
enum SalesStageKind {
OPEN
/// подключён — лид становится партнёром или агентством
WON
LOST
}
/// Этап воронки; набор и порядок настраивает суперадмин
model SalesStage {
id String @id @default(cuid())
name String
kind SalesStageKind @default(OPEN)
sortOrder Int @default(0)
/// пастельный цвет колонки: pink | blue | home | learn | docs | perks
color String @default("blue")
active Boolean @default(true)
createdAt DateTime @default(now())
leads SalesLead[]
}
model SalesLead {
id String @id @default(cuid())
kind SalesLeadKind
name String
/// для заведений: cafe | restaurant | shop | beauty | other
category String?
contactName String?
phone String?
telegram String?
address String?
lat Float?
lng Float?
stageId String
stage SalesStage @relation(fields: [stageId], references: [id])
stageChangedAt DateTime @default(now())
/// продажник, который ведёт лид
ownerId String?
owner AdminUser? @relation("LeadOwner", fields: [ownerId], references: [id])
note String?
lostReason String?
/// во что превратился лид после «Подключён»
partnerId String?
partner Partner? @relation(fields: [partnerId], references: [id])
agencyId String?
agency Agency? @relation(fields: [agencyId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
activities SalesActivity[]
tasks SalesTask[]
@@index([ownerId, stageId])
}
/// История контактов: звонок, встреча, заметка, фото/файл, смена этапа
model SalesActivity {
id String @id @default(cuid())
leadId String
lead SalesLead @relation(fields: [leadId], references: [id], onDelete: Cascade)
authorId String?
/// CALL | MEETING | NOTE | FILE | STAGE | WON
type String
text String?
fileKey String?
fileName String?
mime String?
size Int?
createdAt DateTime @default(now())
@@index([leadId, createdAt])
}
/// Задача продажника с напоминанием в Telegram (рабочий бот) в срок
model SalesTask {
id String @id @default(cuid())
leadId String?
lead SalesLead? @relation(fields: [leadId], references: [id], onDelete: Cascade)
assigneeId String
assignee AdminUser @relation("TaskAssignee", fields: [assigneeId], references: [id])
title String
dueAt DateTime
doneAt DateTime?
remindedAt DateTime?
createdById String?
createdAt DateTime @default(now())
@@index([assigneeId, doneAt, dueAt])
}
+5 -1
View File
@@ -1272,7 +1272,11 @@
"transfer_standardPrice": "Transfer “Standard”, AMD",
"transfer_premiumPrice": "Transfer “Premium”, AMD",
"assign_auto": "Assign orders automatically",
"assign_maxAutoOffers": "How many providers to try automatically"
"assign_maxAutoOffers": "How many providers to try automatically",
"sales_bonusPerVenue": "Sales bonus per venue, ֏",
"sales_bonusPerAgency": "Sales bonus per agency, ֏",
"sales_bonusAgencyPct": "Sales bonus: % of revenue from their agencies’ clients",
"sales_bonusAgencyMonths": "…for how many months after the agency joins"
},
"presets": {
"add": "Add preset",
+5 -1
View File
@@ -1272,7 +1272,11 @@
"transfer_standardPrice": "Traslado «Estándar», AMD",
"transfer_premiumPrice": "Traslado «Premium», AMD",
"assign_auto": "Asignar pedidos automáticamente",
"assign_maxAutoOffers": "Cuántos profesionales probar automáticamente"
"assign_maxAutoOffers": "Cuántos profesionales probar automáticamente",
"sales_bonusPerVenue": "Bono de ventas por local, ֏",
"sales_bonusPerAgency": "Bono de ventas por agencia, ֏",
"sales_bonusAgencyPct": "Bono de ventas: % de ingresos de clientes de sus agencias",
"sales_bonusAgencyMonths": "…durante cuántos meses tras unirse la agencia"
},
"presets": {
"add": "Añadir plantilla",
+5 -1
View File
@@ -1272,7 +1272,11 @@
"transfer_standardPrice": "Տրանսֆեր «Ստանդարտ», ֏",
"transfer_premiumPrice": "Տրանսֆեր «Պրեմիում», ֏",
"assign_auto": "Պատվերների ավտոմատ բաշխում",
"assign_maxAutoOffers": "Քանի կատարող փորձել ավտոմատ"
"assign_maxAutoOffers": "Քանի կատարող փորձել ավտոմատ",
"sales_bonusPerVenue": "Վաճառողի բոնուս հաստատության համար, ֏",
"sales_bonusPerAgency": "Վաճառողի բոնուս գործակալության համար, ֏",
"sales_bonusAgencyPct": "Վաճառողի բոնուս՝ % գործակալությունների հաճախորդների եկամտից",
"sales_bonusAgencyMonths": "…քանի ամիս գործակալության միանալուց հետո"
},
"presets": {
"add": "Ավելացնել նախադրվածք",
+5 -1
View File
@@ -1272,7 +1272,11 @@
"transfer_standardPrice": "Трансфер «Стандарт», ֏",
"transfer_premiumPrice": "Трансфер «Премиум», ֏",
"assign_auto": "Автораспределение заказов",
"assign_maxAutoOffers": "Сколько исполнителей пробовать автоматически"
"assign_maxAutoOffers": "Сколько исполнителей пробовать автоматически",
"sales_bonusPerVenue": "Бонус продажника за заведение, ֏",
"sales_bonusPerAgency": "Бонус продажника за агентство, ֏",
"sales_bonusAgencyPct": "Бонус продажника: % выручки клиентов его агентств",
"sales_bonusAgencyMonths": "…сколько месяцев после подключения агентства"
},
"presets": {
"add": "Добавить пресет",
+5 -1
View File
@@ -1272,7 +1272,11 @@
"transfer_standardPrice": "接机「标准」,֏",
"transfer_premiumPrice": "接机「高级」,֏",
"assign_auto": "自动分配订单",
"assign_maxAutoOffers": "自动尝试的服务人员数量"
"assign_maxAutoOffers": "自动尝试的服务人员数量",
"sales_bonusPerVenue": "每个商户的销售奖金,֏",
"sales_bonusPerAgency": "每个代理的销售奖金,֏",
"sales_bonusAgencyPct": "销售奖金:其代理客户收入的百分比",
"sales_bonusAgencyMonths": "……代理加入后的月数"
},
"presets": {
"add": "添加方案",
+8 -1
View File
@@ -25,6 +25,8 @@ export type DomainEvent =
| { type: 'concierge.message'; messageId: string }
/** диалог передали коллеге — ему уведомление в рабочий бот */
| { type: 'chat.assigned'; threadId: string; assigneeId: string; byAdminId: string }
/** срок задачи продажника — напоминание в рабочий бот */
| { type: 'sales.taskDue'; taskId: string }
| { type: 'lead.submitted'; leadId: string }
| { type: 'clientRequest.created'; requestId: string }
| { type: 'webRequest.created'; requestId: string };
@@ -81,7 +83,12 @@ export type DomainErrorCode =
| 'OFFER_NOT_AVAILABLE'
| 'OFFER_LIMIT_REACHED'
| 'CONCIERGE_SUBSCRIBERS_ONLY'
| 'ASSIGNEE_NO_ACCESS';
| 'ASSIGNEE_NO_ACCESS'
| 'LEAD_NOT_CONVERTED'
| 'ALREADY_CONVERTED'
| 'AGENCY_CODE_FORMAT'
| 'AGENCY_CODE_TAKEN'
| 'REASON_REQUIRED';
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
export const EVENTS_QUEUE = 'barev-events';
+1
View File
@@ -15,4 +15,5 @@ export * from './relocation.ts';
export * from './clientRequests.ts';
export * from './concierge.ts';
export * from './chats.ts';
export * from './sales.ts';
export * from './partners.ts';
+139
View File
@@ -0,0 +1,139 @@
/**
* CRM продажника (этап 4): лиды-заведения и лиды-агентства, воронка, история контактов, задачи,
* «Подключён» → партнёр/агентство одним кликом, бонус по формуле из настроек.
*/
import { botLink, encodeStartPayload, normalizeAgencyCode } from '@barev/core';
import type { Prisma } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { createOwnerInvite } from './partners.ts';
import { getSettings } from './settings.ts';
const LOCALES = ['ru', 'en', 'zh', 'hy', 'es'] as const;
const emptyLocalized = () => Object.fromEntries(LOCALES.map((l) => [l, ''])) as Record<(typeof LOCALES)[number], string>;
/** Ссылка агентства для клиентов: клиентский бот с кодом агентства (подарок и закрепление) */
export function agencyLink(code: string) {
return botLink(process.env.CLIENT_BOT_USERNAME ?? 'barev_club_bot', encodeStartPayload({ agency: code }));
}
async function stageOf(ctx: Ctx, kind: 'WON' | 'LOST') {
const s = await ctx.db.salesStage.findFirst({ where: { kind, active: true }, orderBy: { sortOrder: 'asc' } });
if (!s) throw new DomainError('NOT_FOUND');
return s;
}
/** Перевести лид на другой этап. На «Подключён» — только через convertLead (там создаётся партнёр) */
export async function moveLead(ctx: Ctx, input: { leadId: string; stageId: string; byAdminId: string; lostReason?: string | null }) {
const [lead, stage] = await Promise.all([ctx.db.salesLead.findUnique({ where: { id: input.leadId } }), ctx.db.salesStage.findUnique({ where: { id: input.stageId } })]);
if (!lead || !stage) throw new DomainError('NOT_FOUND');
if (lead.stageId === stage.id) return lead;
if (stage.kind === 'WON' && !lead.partnerId && !lead.agencyId) throw new DomainError('LEAD_NOT_CONVERTED');
if (stage.kind === 'LOST' && !input.lostReason?.trim()) throw new DomainError('REASON_REQUIRED');
const updated = await ctx.db.salesLead.update({
where: { id: lead.id },
data: { stageId: stage.id, stageChangedAt: ctx.now(), lostReason: stage.kind === 'LOST' ? input.lostReason!.trim() : null },
});
await ctx.db.salesActivity.create({ data: { leadId: lead.id, authorId: input.byAdminId, type: 'STAGE', text: stage.kind === 'LOST' ? `${stage.name}: ${input.lostReason!.trim()}` : stage.name } });
return updated;
}
/**
* «Подключён»: заведение становится партнёром клубной карты (и сразу ссылка для собственника в рабочий бот),
* агентство — агентством с кодом (и ссылка для его клиентов). Продажник закрепляется за партнёром — для бонуса.
*/
export async function convertLead(ctx: Ctx, input: { leadId: string; byAdminId: string; agencyCode?: string; commissionPct?: number }) {
const lead = await ctx.db.salesLead.findUnique({ where: { id: input.leadId } });
if (!lead) throw new DomainError('NOT_FOUND');
if (lead.partnerId || lead.agencyId) throw new DomainError('ALREADY_CONVERTED');
const won = await stageOf(ctx, 'WON');
if (lead.kind === 'VENUE') {
const partner = await ctx.db.partner.create({
data: {
name: lead.name,
category: lead.category ?? 'other',
offer: emptyLocalized(),
address: lead.address,
lat: lead.lat,
lng: lead.lng,
phone: lead.phone,
salesId: lead.ownerId,
note: [lead.contactName, lead.telegram].filter(Boolean).join(' · ') || null,
},
});
const invite = await createOwnerInvite(ctx, partner.id);
await ctx.db.salesLead.update({ where: { id: lead.id }, data: { partnerId: partner.id, stageId: won.id, stageChangedAt: ctx.now() } });
await ctx.db.salesActivity.create({ data: { leadId: lead.id, authorId: input.byAdminId, type: 'WON', text: won.name } });
return { kind: 'VENUE' as const, partnerId: partner.id, url: invite.url };
}
const code = normalizeAgencyCode(input.agencyCode ?? '');
if (!code) throw new DomainError('AGENCY_CODE_FORMAT');
if (await ctx.db.agency.findUnique({ where: { code } })) throw new DomainError('AGENCY_CODE_TAKEN');
const settings = await getSettings(ctx.db);
const agency = await ctx.db.agency.create({
data: {
code,
name: lead.name,
commissionPct: input.commissionPct ?? settings['agency.defaultCommissionPct'],
contact: [lead.contactName, lead.phone, lead.telegram].filter(Boolean).join(' · ') || null,
salesId: lead.ownerId,
},
});
await ctx.db.salesLead.update({ where: { id: lead.id }, data: { agencyId: agency.id, stageId: won.id, stageChangedAt: ctx.now() } });
await ctx.db.salesActivity.create({ data: { leadId: lead.id, authorId: input.byAdminId, type: 'WON', text: won.name } });
return { kind: 'AGENCY' as const, agencyId: agency.id, url: agencyLink(code) };
}
/** Задачи, срок которых наступил, — напоминание продажнику в рабочий бот (воркер раз в минуту) */
export async function dueSalesTasks(ctx: Ctx) {
const due = await ctx.db.salesTask.findMany({ where: { doneAt: null, remindedAt: null, dueAt: { lte: ctx.now() } }, select: { id: true }, take: 100 });
for (const t of due) {
await ctx.db.salesTask.update({ where: { id: t.id }, data: { remindedAt: ctx.now() } });
await ctx.notify.emit({ type: 'sales.taskDue', taskId: t.id });
}
return due.length;
}
/**
* Бонус продажника за период по формуле из настроек:
* подключённые заведения × ставка + агентства × ставка + % выручки клиентов его агентств
* (первые N месяцев после подключения агентства).
*/
export async function salesBonus(ctx: Ctx, salesId: string, from: Date, to: Date) {
const s = await getSettings(ctx.db);
const months = s['sales.bonusAgencyMonths'];
const [venues, agencies, myAgencies] = await Promise.all([
ctx.db.partner.count({ where: { salesId, createdAt: { gte: from, lt: to } } }),
ctx.db.agency.count({ where: { salesId, createdAt: { gte: from, lt: to } } }),
ctx.db.agency.findMany({ where: { salesId }, select: { id: true, createdAt: true } }),
]);
// выручка клиентов агентства, пока агентство «молодое» (N месяцев с подключения)
let revenue = 0;
for (const a of myAgencies) {
const until = new Date(a.createdAt);
until.setMonth(until.getMonth() + months);
const lo = from > a.createdAt ? from : a.createdAt;
const hi = to < until ? to : until;
if (lo >= hi) continue;
const where: Prisma.ClientWhereInput = { agencyId: a.id };
const [orders, subs] = await Promise.all([
ctx.db.order.aggregate({ _sum: { totalPrice: true }, where: { client: where, paymentStatus: 'PAID', paidAt: { gte: lo, lt: hi } } }),
ctx.db.subscription.aggregate({ _sum: { amount: true }, where: { client: where, source: 'PAID', createdAt: { gte: lo, lt: hi } } }),
]);
revenue += (orders._sum.totalPrice ?? 0) + (subs._sum.amount ?? 0);
}
const fromVenues = venues * s['sales.bonusPerVenue'];
const fromAgencies = agencies * s['sales.bonusPerAgency'];
const fromRevenue = Math.round((revenue * s['sales.bonusAgencyPct']) / 100);
return {
venues,
agencies,
revenue,
fromVenues,
fromAgencies,
fromRevenue,
total: fromVenues + fromAgencies + fromRevenue,
rules: { perVenue: s['sales.bonusPerVenue'], perAgency: s['sales.bonusPerAgency'], pct: s['sales.bonusAgencyPct'], months },
};
}
+92
View File
@@ -0,0 +1,92 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { convertLead, DomainError, dueSalesTasks, moveLead, salesBonus } from '../src/index.ts';
import { db, fixtures, makeCtx, NOW } from './helpers.ts';
beforeEach(async () => {
await fixtures();
await db.salesStage.createMany({
data: [
{ id: 's_new', name: 'Новый', kind: 'OPEN', sortOrder: 0 },
{ id: 's_meet', name: 'Встреча', kind: 'OPEN', sortOrder: 1 },
{ id: 's_won', name: 'Подключён', kind: 'WON', sortOrder: 2 },
{ id: 's_lost', name: 'Отказ', kind: 'LOST', sortOrder: 3 },
],
});
});
afterAll(() => db.$disconnect());
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
const setting = (key: string, value: number) => db.setting.upsert({ where: { key }, create: { key, value }, update: { value } });
describe('CRM продажника', () => {
it('этапы: «Подключён» только через подключение, «Отказ» — с причиной; всё пишется в историю', async () => {
const { ctx } = makeCtx(NOW);
const sales = await db.adminUser.create({ data: { name: 'Давид', roles: ['SALES'] } });
const lead = await db.salesLead.create({ data: { kind: 'VENUE', name: 'Lavash', stageId: 's_new', ownerId: sales.id } });
await moveLead(ctx, { leadId: lead.id, stageId: 's_meet', byAdminId: sales.id });
expect(await code(moveLead(ctx, { leadId: lead.id, stageId: 's_won', byAdminId: sales.id }))).toBe('LEAD_NOT_CONVERTED');
expect(await code(moveLead(ctx, { leadId: lead.id, stageId: 's_lost', byAdminId: sales.id }))).toBe('REASON_REQUIRED');
await moveLead(ctx, { leadId: lead.id, stageId: 's_lost', byAdminId: sales.id, lostReason: 'Дорого' });
const after = await db.salesLead.findUniqueOrThrow({ where: { id: lead.id }, include: { activities: { orderBy: { createdAt: 'asc' } } } });
expect(after).toMatchObject({ stageId: 's_lost', lostReason: 'Дорого' });
expect(after.activities.map((a) => a.text)).toEqual(['Встреча', 'Отказ: Дорого']);
});
it('заведение «Подключён»: партнёр с продажником и одноразовая ссылка собственнику', async () => {
const { ctx } = makeCtx(NOW);
const sales = await db.adminUser.create({ data: { name: 'Давид', roles: ['SALES'] } });
const lead = await db.salesLead.create({ data: { kind: 'VENUE', name: 'Lavash', category: 'restaurant', address: 'Туманяна 21', lat: 40.18, lng: 44.51, stageId: 's_meet', ownerId: sales.id } });
const r = await convertLead(ctx, { leadId: lead.id, byAdminId: sales.id });
expect(r.kind).toBe('VENUE');
expect(r.url).toMatch(/\?start=po-/);
const partner = await db.partner.findUniqueOrThrow({ where: { id: (r as { partnerId: string }).partnerId } });
expect(partner).toMatchObject({ name: 'Lavash', category: 'restaurant', salesId: sales.id, lat: 40.18 });
expect(partner.ownerInviteCode).toBeTruthy();
expect(await db.salesLead.findUniqueOrThrow({ where: { id: lead.id } })).toMatchObject({ stageId: 's_won', partnerId: partner.id });
expect(await code(convertLead(ctx, { leadId: lead.id, byAdminId: sales.id }))).toBe('ALREADY_CONVERTED');
});
it('агентство «Подключён»: код уникален, ссылка для клиентов, % по умолчанию из настроек', async () => {
const { ctx } = makeCtx(NOW);
const sales = await db.adminUser.create({ data: { name: 'Давид', roles: ['SALES'] } });
const lead = await db.salesLead.create({ data: { kind: 'AGENCY', name: 'Yerevan Homes', stageId: 's_new', ownerId: sales.id } });
expect(await code(convertLead(ctx, { leadId: lead.id, byAdminId: sales.id, agencyCode: 'домик' }))).toBe('AGENCY_CODE_FORMAT');
expect(await code(convertLead(ctx, { leadId: lead.id, byAdminId: sales.id, agencyCode: 'domik' }))).toBe('AGENCY_CODE_TAKEN');
const r = await convertLead(ctx, { leadId: lead.id, byAdminId: sales.id, agencyCode: 'yhomes' });
expect(r.url).toMatch(/\?start=a-YHOMES$/);
expect(await db.agency.findUniqueOrThrow({ where: { code: 'YHOMES' } })).toMatchObject({ name: 'Yerevan Homes', commissionPct: 10, salesId: sales.id });
});
it('задачи: в срок — одно напоминание, выполненные и будущие — нет', async () => {
const { ctx, events, clock } = makeCtx(NOW);
const sales = await db.adminUser.create({ data: { name: 'Давид', roles: ['SALES'] } });
const due = await db.salesTask.create({ data: { assigneeId: sales.id, title: 'Позвонить', dueAt: new Date(NOW.getTime() - 60_000) } });
await db.salesTask.create({ data: { assigneeId: sales.id, title: 'Потом', dueAt: new Date(NOW.getTime() + 3600_000) } });
await db.salesTask.create({ data: { assigneeId: sales.id, title: 'Сделано', dueAt: new Date(NOW.getTime() - 60_000), doneAt: NOW } });
expect(await dueSalesTasks(ctx)).toBe(1);
expect(events).toEqual([{ type: 'sales.taskDue', taskId: due.id }]);
expect(await dueSalesTasks(ctx)).toBe(0);
clock.now = new Date(NOW.getTime() + 2 * 3600_000);
expect(await dueSalesTasks(ctx)).toBe(1);
});
it('бонус: ставки за заведения и агентства + % выручки клиентов агентства в первые N месяцев', async () => {
const { ctx } = makeCtx(NOW);
const sales = await db.adminUser.create({ data: { name: 'Давид', roles: ['SALES'] } });
const from = new Date(NOW.getTime() - 10 * 86_400_000);
const to = new Date(NOW.getTime() + 86_400_000);
expect((await salesBonus(ctx, sales.id, from, to)).total).toBe(0);
await setting('sales.bonusPerVenue', 10000);
await setting('sales.bonusPerAgency', 20000);
await setting('sales.bonusAgencyPct', 5);
await db.partner.create({ data: { name: 'Cafe', category: 'cafe', offer: {}, salesId: sales.id, createdAt: NOW } });
const agency = await db.agency.create({ data: { code: 'NEWAG', name: 'New', salesId: sales.id, createdAt: new Date(NOW.getTime() - 86_400_000) } });
const user = await db.user.create({ data: { telegramId: 990001n } });
const client = await db.client.create({ data: { userId: user.id, cardCode: 'BONUS1', agencyId: agency.id, consentAt: NOW } });
await db.subscription.create({ data: { clientId: client.id, plan: 'BAREV_PLUS', source: 'PAID', amount: 40000, startsAt: NOW, endsAt: new Date(NOW.getTime() + 30 * 86_400_000), createdAt: NOW } });
const b = await salesBonus(ctx, sales.id, from, to);
expect(b).toMatchObject({ venues: 1, agencies: 1, revenue: 40000, fromVenues: 10000, fromAgencies: 20000, fromRevenue: 2000, total: 32000 });
});
});
+18
View File
@@ -291,6 +291,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.104.0
version: 5.104.3(react@19.3.1)
leaflet:
specifier: 1.9.4
version: 1.9.4
qrcode:
specifier: ^1.5.4
version: 1.5.4
@@ -304,6 +307,9 @@ importers:
'@barev/api':
specifier: workspace:*
version: link:../api
'@types/leaflet':
specifier: ^1.9.22
version: 1.9.22
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
@@ -1730,6 +1736,9 @@ packages:
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/leaflet@1.9.22':
resolution: {integrity: sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==}
'@types/lodash@4.17.26':
resolution: {integrity: sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==}
@@ -2224,6 +2233,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
light-my-request@6.8.0:
resolution: {integrity: sha512-5Q8PjoSEdtGIB8fikZsTem5vgRo4F2mR6vUKTDW/H1g6MtAt8e3duQbg/QxL5yzg6BZhor2KOUm3hhWMWLTZwg==}
@@ -3852,6 +3864,10 @@ snapshots:
'@types/geojson@7946.0.16': {}
'@types/leaflet@1.9.22':
dependencies:
'@types/geojson': 7946.0.16
'@types/lodash@4.17.26': {}
'@types/node@24.19.2':
@@ -4389,6 +4405,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
leaflet@1.9.4: {}
light-my-request@6.8.0:
dependencies:
'@fastify/deepmerge': 3.2.2