diff --git a/apps/api/package.json b/apps/api/package.json index 39e13de..fb1c5e4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/scripts/dev-crm-demo.ts b/apps/api/scripts/dev-crm-demo.ts new file mode 100644 index 0000000..9b5e59f --- /dev/null +++ b/apps/api/scripts/dev-crm-demo.ts @@ -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); diff --git a/apps/api/src/routers/backoffice/crm.ts b/apps/api/src/routers/backoffice/crm.ts new file mode 100644 index 0000000..1135054 --- /dev/null +++ b/apps/api/src/routers/backoffice/crm.ts @@ -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 } }); + }), +}); diff --git a/apps/api/src/routers/backoffice/index.ts b/apps/api/src/routers/backoffice/index.ts index 1e29779..d18f011 100644 --- a/apps/api/src/routers/backoffice/index.ts +++ b/apps/api/src/routers/backoffice/index.ts @@ -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, }); diff --git a/apps/api/src/routers/backoffice/partners.ts b/apps/api/src/routers/backoffice/partners.ts new file mode 100644 index 0000000..5d81f79 --- /dev/null +++ b/apps/api/src/routers/backoffice/partners.ts @@ -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 = (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, + 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, 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 | 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 } })), +}); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index c1ca651..45907a1 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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; diff --git a/apps/backoffice/package.json b/apps/backoffice/package.json index 4bbd3f5..d10cd16 100644 --- a/apps/backoffice/package.json +++ b/apps/backoffice/package.json @@ -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", diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx index b52fa66..c61c3ef 100644 --- a/apps/backoffice/src/App.tsx +++ b/apps/backoffice/src/App.tsx @@ -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 = ; else if ((path === '/chats' || threadId) && canChat) page = ; else if (path === '/orders' && canOrders) page = ; else if (orderId && canOrders) page = ; else if (path === '/providers' && canOrders) page = ; else if (providerId && canOrders) page = ; + else if (path === '/crm' && canCrm) page = ; + else if (leadId && canCrm) page = ; + else if (path === '/agencies' && isSuper) page = ; + else if (agencyId && isSuper) page = ; + else if (path === '/venues' && isSuper) page = ; + else if (venueId && isSuper) page = ; else if (path === '/clients' && current) page = ; else if (clientId && items.some((n) => n.path === '/clients')) page = ; else if (path === '/team' && isSuper) page = ; diff --git a/apps/backoffice/src/components/map.tsx b/apps/backoffice/src/components/map.tsx new file mode 100644 index 0000000..533fb9d --- /dev/null +++ b/apps/backoffice/src/components/map.tsx @@ -0,0 +1,96 @@ +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import { useEffect, useRef } from 'react'; + +/** Пастельные цвета этапов — как на сайте */ +export const STAGE_COLORS: Record = { + 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(null); + const map = useRef(null); + const layer = useRef(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(`${escape(p.title)}${p.sub ? `
${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
; +} + +const escape = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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; +} diff --git a/apps/backoffice/src/crm.css b/apps/backoffice/src/crm.css new file mode 100644 index 0000000..616763b --- /dev/null +++ b/apps/backoffice/src/crm.css @@ -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; + } +} diff --git a/apps/backoffice/src/i18n.tsx b/apps/backoffice/src/i18n.tsx index ebede46..96f61d5 100644 --- a/apps/backoffice/src/i18n.tsx +++ b/apps/backoffice/src/i18n.tsx @@ -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': 'Նոր պատվեր', diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx index c892561..9feb586 100644 --- a/apps/backoffice/src/main.tsx +++ b/apps/backoffice/src/main.tsx @@ -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'; diff --git a/apps/backoffice/src/pages/Agencies.tsx b/apps/backoffice/src/pages/Agencies.tsx new file mode 100644 index 0000000..834b092 --- /dev/null +++ b/apps/backoffice/src/pages/Agencies.tsx @@ -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 ( +
+ {url} + +
+ ); +} +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 ( + <> +
+

{t('ag.title')}

+ +
+ {!list.data ? ( + + ) : list.data.length === 0 ? ( +
{t('ag.empty')}
+ ) : ( +
+ + + + + + + + + + + + {list.data.map((a) => ( + navigate(`/agencies/${a.id}`)} data-off={!a.active || undefined}> + + + + + + + ))} + +
{t('ag.col.name')}{t('ag.col.clients')}{t('ag.col.gifts')}{t('ag.col.month')}{t('ag.col.balance')}
+ e.stopPropagation()}> + + + {a.name} + {[a.code, `${a.commissionPct}%`, a.sales?.name].filter(Boolean).join(' · ')} + {!a.active && {t('ag.inactive')}} + + + {a.clients}{a.gifts}{amd(a.thisMonth)} + {amd(a.balance)} +
+

+ {t('ag.hint')} +

+
+ )} + {creating && 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 = ( + + + {t('ag.back')} + + ); + if (!a.data) + return ( + <> + {back} + {a.isError ?
{errText(t, a.error)}
: } + + ); + const d = a.data; + return ( + <> + {back} +
+ +
+

+ {d.name} +

+
+ {d.code} + {t('ag.pct', { n: d.commissionPct })} + {d.sales && {t('ag.byS', { name: d.sales.name })}} + {t('prv.since', { date: date(d.createdAt) })} + {!d.active && {t('ag.inactive')}} +
+
+ +
+ +
+
+
+
+

{t('ag.link')}

+
+

+ {t('ag.linkHint', { code: d.code })} +

+ +
+
+
+

{t('ag.clients')}

+ + {d.clients.length} · {t('ag.giftsN', { n: d.gifts })} + +
+ {d.clients.length === 0 ? ( +

{t('ag.noClients')}

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

+ {t('ag.payouts')}: {d.payouts.slice(0, 5).map((x) => `${amd(x.amount)}${x.paidAt ? ` (${date(x.paidAt)})` : ''}`).join(', ')} +

+ )} +
+
+
+

{t('crm.contacts')}

+
+

{d.contact ?? '—'}

+ {d.note &&

{d.note}

} +
+
+
+ {dlg === 'edit' && setDlg(null)} onDone={refresh} />} + {dlg === 'payout' && 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 ( + + + + + } + > +
+ + setF({ ...f, name: e.target.value })} /> + + + setF({ ...f, code: e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '') })} /> + +
+
+ + setF({ ...f, commissionPct: e.target.value.replace(/\D/g, '') })} /> + + + + +
+ + setF({ ...f, contact: e.target.value })} placeholder={t('ag.contactPh')} /> + + +