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:
1 parent
a848bb13e2
commit
ac786cc5cc
30 files changed
+4311
-11
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user