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 {