Compare commits

..
Author SHA1 Message Date
zabudko-ruandClaude Opus 5.5 3489e0b816 Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 22:55:29 +04:00
157 changed files with 971 additions and 29642 deletions

No files matched your search

-2
View File
@@ -23,8 +23,6 @@ API_URL=http://localhost:4000
CLIENT_MINIAPP_URL=http://localhost:5174
PRO_MINIAPP_URL=http://localhost:5175
ADMIN_URL=http://localhost:5173
# новая админка; если задан https-адрес, кнопка «Открыть» в уведомлениях о сообщениях ведёт в её чаты
BACKOFFICE_URL=http://localhost:5176
API_PORT=4000
# ---- Админка ----
+3
View File
@@ -12,3 +12,6 @@ packages/db/src/generated
# личные документы в корне (выписки, паспорта) — не коммитим
/*.pdf
# рабочие копии веток Claude Code
.claude/worktrees
+6 -10
View File
@@ -34,16 +34,12 @@ export function Settings() {
<div className="form-grid">
{SETTING_KEYS.map((k) => (
<Field key={k} label={t(`admin.settings.${k.replace('.', '_')}`)}>
{typeof draft[k] === 'boolean' ? (
<input type="checkbox" checked={draft[k] as boolean} onChange={(e) => setDraft({ ...draft, [k]: e.target.checked })} style={{ width: 22, height: 22 }} />
) : (
<input
className="bc-input"
type={typeof draft[k] === 'number' ? 'number' : 'text'}
value={String(draft[k])}
onChange={(e) => setDraft({ ...draft, [k]: typeof draft[k] === 'number' ? Number(e.target.value) : e.target.value })}
/>
)}
<input
className="bc-input"
type={typeof draft[k] === 'number' ? 'number' : 'text'}
value={String(draft[k])}
onChange={(e) => setDraft({ ...draft, [k]: typeof draft[k] === 'number' ? Number(e.target.value) : e.target.value })}
/>
</Field>
))}
</div>
+3 -3
View File
@@ -72,7 +72,7 @@ export function WebRequests() {
<b>{r.number}</b>
<div className="bc-small bc-muted">{t('admin.webRequests.created', { date: dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) })}</div>
</td>
<td className="bc-small">{r.preferredAt ? dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : '—'}</td>
<td className="bc-small">{dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</td>
<td>
<b>{t(`params.cleaning.cleaningType.${p.cleaningType ?? 'regular'}`)}</b>
<div className="bc-small">
@@ -83,8 +83,8 @@ export function WebRequests() {
)}
</td>
<td className="bc-small">
{r.district ? t(`districts.${r.district.code}`) : '—'}
<div>{r.addressText ?? '—'}</div>
{t(`districts.${r.district.code}`)}
<div>{r.addressText}</div>
{r.note && <div className="bc-muted">{r.note}</div>}
</td>
<td>
-2
View File
@@ -2,9 +2,7 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
// ADMIN_BASE=/old/ — в продакшене старая админка живёт по адресу admin.barevclub.com/old/
export default defineConfig({
base: process.env.ADMIN_BASE ?? '/',
plugins: [react()],
server: {
host: true,
+1 -6
View File
@@ -14,18 +14,13 @@
"typecheck": "tsc --noEmit",
"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:crm-demo": "tsx scripts/dev-crm-demo.ts",
"dev:partners-demo": "tsx scripts/dev-partners-demo.ts",
"dev:points-demo": "tsx scripts/dev-points-demo.ts"
"dev:bo-demo": "tsx scripts/dev-backoffice-demo.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1152.0",
"@aws-sdk/s3-request-presigner": "^3.1152.0",
"@barev/core": "workspace:*",
"@barev/db": "workspace:*",
"@barev/i18n": "workspace:*",
"@barev/services": "workspace:*",
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.1.0",
+1 -4
View File
@@ -28,9 +28,6 @@ await db.order.deleteMany({ where: { clientId: { in: oldClientIds } } });
await db.subscription.deleteMany({ where: { clientId: { in: oldClientIds } } });
await db.cardUsage.deleteMany({ where: { clientId: { in: oldClientIds } } });
await db.auditLog.deleteMany({ where: { entityId: { in: oldClientIds } } });
// демо-чаты (dev:chats-demo) ссылаются на клиентов
await db.chatThread.deleteMany({ where: { clientId: { in: oldClientIds } } });
await db.conciergeMessage.deleteMany({ where: { clientId: { in: oldClientIds } } });
await db.client.deleteMany({ where: { id: { in: oldClientIds } } });
await db.user.deleteMany({ where: { id: { in: old.map((u) => u.id) } } });
await db.lead.deleteMany({ where: { contact: { startsWith: '+374 99 00' } } });
@@ -43,7 +40,7 @@ const [services, agencies, providers, partners, owner] = await Promise.all([
db.adminUser.findFirst({ where: { roles: { has: 'SUPERADMIN' } } }),
]);
const PEOPLE: [string, string | null, string | null, 'ru' | 'en' | 'hy' | 'zh' | 'es'][] = [
const PEOPLE: [string, string | null, string, 'ru' | 'en' | 'hy' | 'zh' | 'es'][] = [
['Мария', 'Иванова', 'masha_iv', 'ru'],
['Дмитрий', 'Козлов', 'dkozlov', 'ru'],
['Sarah', 'Miller', 'sarah_m', 'en'],
-218
View File
@@ -1,218 +0,0 @@
/**
* Локальная разработка: демо-переписка для раздела «Чаты» новой админки (этап 2).
* pnpm --filter @barev/api dev:bo-demo — сначала демо-клиенты
* pnpm --filter @barev/api dev:chats-demo — потом диалоги
* Повторный запуск пересоздаёт диалоги демо-клиентов. Добавляет двух сотрудников: консьержа и поддержку.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { getDb } from '@barev/db';
import { providerCurrentOrder, silentNotifier, type Ctx } from '@barev/services';
import { putFile } from '@barev/services/storage';
import { env } from '../src/env.ts';
if (env.isProd) throw new Error('Только для разработки');
const db = getDb();
const MIN = 60_000;
const now = Date.now();
const ago = (m: number) => new Date(now - m * MIN);
async function clientByTg(tg: bigint) {
const u = await db.user.findUnique({ where: { telegramId: tg }, include: { client: true } });
if (!u?.client) throw new Error(`Нет демо-клиента ${tg} — сначала запустите pnpm --filter @barev/api dev:bo-demo`);
return u.client;
}
async function staff(name: string, roles: ('SUPPORT' | 'CONCIERGE' | 'SUPERADMIN')[]) {
return (await db.adminUser.findFirst({ where: { name } })) ?? db.adminUser.create({ data: { name, roles } });
}
const [owner, operator, anna, gor] = await Promise.all([
db.adminUser.findFirst({ where: { roles: { has: 'SUPERADMIN' } } }),
db.adminUser.findFirst({ where: { roles: { hasEvery: ['SUPPORT', 'CONCIERGE'] } } }),
staff('Анна Мелконян', ['CONCIERGE']),
staff('Гор Авакян', ['SUPPORT']),
]);
if (!owner || !operator) throw new Error('Нет суперадмина или оператора — запустите seed');
const arsen = await clientByTg(100001003n); // Elite
const dmitry = await clientByTg(100001001n); // Barev+
const maria = await clientByTg(100001000n); // без подписки
const sarah = await clientByTg(100001002n); // Barev+, пишет в поддержку
const alex = await clientByTg(100001005n); // без подписки
const katya = await clientByTg(100001004n); // Barev+
const natalia = await clientByTg(100001007n); // без подписки
const lilit = await clientByTg(100001025n); // Barev+
const provider = await db.provider.findFirst({ where: { userId: { not: null }, active: true }, orderBy: { createdAt: 'asc' } });
// второй исполнитель — с заказом в работе, чтобы было видно «пишет по заказу №…»
const busy = await db.provider.findFirst({ where: { userId: { not: null }, active: true, orders: { some: { status: { in: ['EN_ROUTE', 'CLEANING', 'ON_SITE', 'REPAIRING'] } } } } });
const providerIds = [provider?.id, busy?.id].filter((x): x is string => !!x);
const svc: Ctx = { db, notify: silentNotifier, now: () => new Date() };
// демо-даты: у «занятого» исполнителя заказ идёт сейчас, у первого — следующий заказ завтра
if (busy) {
const o = await db.order.findFirst({ where: { providerId: busy.id, status: { in: ['EN_ROUTE', 'CLEANING', 'ON_SITE', 'REPAIRING'] } } });
if (o) await db.order.update({ where: { id: o.id }, data: { scheduledAt: ago(90) } });
}
if (provider) {
const o = await db.order.findFirst({ where: { providerId: provider.id, status: 'ASSIGNED' } });
if (o) await db.order.update({ where: { id: o.id }, data: { scheduledAt: new Date(now + 20 * 60 * MIN) } });
}
const clientIds = [arsen, dmitry, maria, sarah, alex, katya, natalia, lilit].map((c) => c.id);
await db.chatThread.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, { providerId: { in: providerIds } }] } });
await db.conciergeMessage.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, { providerId: { in: providerIds } }] } });
await db.client.updateMany({ where: { id: { in: clientIds } }, data: { chatChannel: null } });
await db.client.update({ where: { id: sarah.id }, data: { chatChannel: 'SUPPORT' } });
// файлы: фото от клиента и PDF от команды — в локальное хранилище
const root = path.resolve(import.meta.dirname, '../../..');
const photo = readFileSync(path.join(root, 'flowers/S.jpg'));
const photoKey = await putFile('chats/demo', photo, 'image/jpeg', 'kitchen.jpg');
const pdf = Buffer.from(
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 300 144]/Parent 2 0 R/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj 4 0 obj<</Length 52>>stream\nBT /F1 18 Tf 30 70 Td (Barev Club - checklist) Tj ET\nendstream endobj 5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF',
);
const pdfKey = await putFile('chats/demo', pdf, 'application/pdf', 'Чек-лист переезда.pdf');
type Msg = {
author: 'CLIENT' | 'PROVIDER' | 'OPERATOR' | 'SYSTEM';
text: string;
at: number;
by?: string;
internal?: boolean;
file?: { fileKey: string; fileName: string; mime: string; size: number };
param?: string;
deliveryError?: string;
source?: string;
};
async function thread(
peer: { clientId: string } | { providerId: string },
channel: 'SUPPORT' | 'CONCIERGE',
opts: { assigneeId?: string; status?: 'OPEN' | 'CLOSED'; waiting?: number },
messages: Msg[],
) {
const last = Math.min(...messages.map((m) => m.at));
const t = await db.chatThread.create({
data: {
...peer,
channel,
status: opts.status ?? 'OPEN',
assigneeId: opts.assigneeId,
lastMessageAt: ago(last),
waitingSince: opts.waiting != null ? ago(opts.waiting) : null,
closedAt: opts.status === 'CLOSED' ? ago(last) : null,
createdAt: ago(Math.max(...messages.map((m) => m.at))),
},
});
for (const m of messages) {
const incoming = m.author === 'CLIENT' || m.author === 'PROVIDER';
// исполнитель: заказ, который был текущим в момент сообщения
const order = 'providerId' in peer && m.author === 'PROVIDER' ? await providerCurrentOrder(svc, peer.providerId, ago(m.at)) : null;
await db.conciergeMessage.create({
data: {
threadId: t.id,
...peer,
orderId: order?.id ?? null,
author: m.author,
adminUserId: m.by,
text: m.text,
internal: m.internal ?? m.author === 'SYSTEM',
source: m.source ?? (incoming ? 'BOT' : 'ADMIN'),
fileName: m.file?.fileName ?? m.param ?? null,
fileKey: m.file?.fileKey,
mime: m.file?.mime,
size: m.file?.size,
deliveryError: m.deliveryError,
// ждущие ответа входящие ещё не прочитаны
readAt: incoming && opts.waiting != null && m.at <= opts.waiting ? null : ago(m.at - 1),
createdAt: ago(m.at),
},
});
}
}
const kitchen = { fileKey: photoKey, fileName: 'kitchen.jpg', mime: 'image/jpeg', size: photo.length };
const checklist = { fileKey: pdfKey, fileName: 'Чек-лист переезда.pdf', mime: 'application/pdf', size: pdf.length };
// ── консьерж ──
await thread({ clientId: arsen.id }, 'CONCIERGE', { assigneeId: anna.id, waiting: 4 }, [
{ author: 'CLIENT', text: 'Добрый вечер! В субботу у жены день рождения. Можете забронировать столик на четверых где-нибудь с видом на Арарат?', at: 190 },
{ author: 'SYSTEM', text: 'took', at: 186, by: anna.id },
{ author: 'OPERATOR', text: 'Добрый вечер, Арсен! Поздравляем вашу супругу 🌸 Во сколько удобно и есть ли пожелания по кухне?', at: 185, by: anna.id },
{ author: 'CLIENT', text: 'К 19:00. Армянская или грузинская, без громкой музыки.', at: 170 },
{ author: 'OPERATOR', text: 'Elite: предпочитает звонок после 18:00, в прошлый раз просил столик у окна.', at: 168, by: anna.id, internal: true },
{ author: 'OPERATOR', text: 'Предлагаю «Lavash» на Туманяна или «Ташир Пицца» на крыше Каскада. В «Lavash» есть тихий зал и вид на Арарат из окон. Бронировать?', at: 150, by: anna.id },
{ author: 'CLIENT', text: 'Давайте Lavash. И можно торт заказать туда же?', at: 4 },
]);
await thread({ clientId: dmitry.id }, 'CONCIERGE', { waiting: 18 }, [
{ author: 'CLIENT', text: 'Здравствуйте! Нужен нотариус с переводом паспорта на армянский — на этой неделе. Сколько это стоит и где ближе к Кентрону?', at: 20 },
{ author: 'CLIENT', text: 'И ещё вопрос: на кухне после клининга остались разводы на фасадах, вот фото.', at: 18, file: kitchen },
]);
await thread({ clientId: katya.id }, 'CONCIERGE', { assigneeId: owner.id }, [
{ author: 'CLIENT', text: 'Подскажите, что нужно сделать в первую неделю после переезда?', at: 60 * 26 },
{ author: 'OPERATOR', text: 'Екатерина, собрали для вас чек-лист: SIM-карта, банк, регистрация, соцкарта. Всё по шагам в файле.', at: 60 * 25, by: owner.id, file: checklist },
{ author: 'CLIENT', text: 'Спасибо огромное, очень удобно!', at: 60 * 24 },
{ author: 'OPERATOR', text: 'Рады помочь! Если с банком будут сложности — напишите, подскажем отделение, где говорят по-русски.', at: 60 * 24 - 5, by: owner.id },
]);
await thread({ clientId: lilit.id }, 'CONCIERGE', { assigneeId: anna.id, waiting: 9 }, [
{ author: 'CLIENT', text: 'Բարև ձեզ, ուզում եմ տունը մաքրել շաբաթ օրը', at: 40 },
{ author: 'SYSTEM', text: 'moved', at: 35, by: gor.id, param: 'CONCIERGE' },
{ author: 'OPERATOR', text: 'Բարև, Լիլիթ։ Շաբաթ օրը ազատ է 11:00-ից։ Քանի՞ սենյակ է։', at: 30, by: anna.id },
{ author: 'CLIENT', text: 'Երեք սենյակ, խնդրում եմ 11:00', at: 9, source: 'MINIAPP' },
]);
// ── поддержка ──
await thread({ clientId: sarah.id }, 'SUPPORT', { waiting: 52 }, [
{ author: 'CLIENT', text: 'Hi! The cleaner was supposed to come at 10:00 but nobody showed up. Order #1042. Could you check please?', at: 52 },
{ author: 'CLIENT', text: 'Still waiting…', at: 31 },
]);
await thread({ clientId: maria.id }, 'SUPPORT', { assigneeId: operator.id }, [
{ author: 'CLIENT', text: 'Можно перенести уборку с четверга на пятницу?', at: 95 },
{ author: 'SYSTEM', text: 'took', at: 90, by: operator.id },
{ author: 'OPERATOR', text: 'Мария, да, перенесли на пятницу 12:00. Исполнитель тот же.', at: 88, by: operator.id },
{ author: 'CLIENT', text: 'Отлично, спасибо!', at: 80 },
{ author: 'OPERATOR', text: 'Хорошего дня 🙂', at: 79, by: operator.id },
]);
await thread({ clientId: natalia.id }, 'SUPPORT', { assigneeId: gor.id }, [
{ author: 'CLIENT', text: 'Как получить подарочный месяц Barev+?', at: 60 * 5 },
{ author: 'OPERATOR', text: 'Наталья, подарочный месяц включается по коду агентства. Отправьте код командой /code.', at: 60 * 4, by: gor.id, deliveryError: 'UNREACHABLE' },
{ author: 'OPERATOR', text: 'Клиентка заблокировала бота — сообщение не дошло. Позвонить по телефону из карточки.', at: 60 * 4 - 2, by: gor.id, internal: true },
]);
await thread({ clientId: alex.id }, 'SUPPORT', { assigneeId: gor.id, status: 'CLOSED' }, [
{ author: 'CLIENT', text: 'Hello, do you work on Sundays?', at: 60 * 30 },
{ author: 'OPERATOR', text: 'Hi Alex! Yes, cleaning is available on Sundays from 10:00 to 18:00.', at: 60 * 29, by: gor.id },
{ author: 'CLIENT', text: 'Great, thanks', at: 60 * 29 - 10 },
{ author: 'SYSTEM', text: 'closed', at: 60 * 29 - 12, by: gor.id },
]);
if (busy && busy.id !== provider?.id) {
await thread({ providerId: busy.id }, 'SUPPORT', { waiting: 3 }, [
{ author: 'PROVIDER', text: 'Клиентка просит дополнительно помыть балкон — это входит в заказ или считать отдельно?', at: 3 },
]);
}
if (provider) {
await thread({ providerId: provider.id }, 'SUPPORT', { waiting: 7 }, [
{ author: 'PROVIDER', text: 'Здравствуйте, по заказу на Абовяна нет кода от подъезда, клиент не отвечает. Что делать?', at: 7 },
]);
}
// ── шаблоны ответов ──
const TEMPLATES: { channel: 'SUPPORT' | 'CONCIERGE' | null; title: string; text: string }[] = [
{ channel: null, title: 'Приветствие', text: 'Здравствуйте, {name}! Чем можем помочь?' },
{ channel: null, title: 'Уточняем', text: '{name}, уточняем детали и вернёмся с ответом в течение 15 минут.' },
{ channel: 'SUPPORT', title: 'Перенос заказа', text: 'Перенесли заказ. Исполнитель получил новое время, напоминание придёт за час.' },
{ channel: 'CONCIERGE', title: 'Бронь готова', text: 'Готово! Забронировали на ваше имя. Если планы изменятся — просто напишите сюда.' },
];
await db.chatTemplate.deleteMany({ where: { title: { in: TEMPLATES.map((t) => t.title) } } });
for (const t of TEMPLATES) await db.chatTemplate.create({ data: { ...t, createdById: owner.id } });
console.log(`Демо-чаты готовы: ${8 + providerIds.length} диалогов, ${TEMPLATES.length} шаблона. Сотрудники: ${anna.name} (консьерж), ${gor.name} (поддержка).`);
process.exit(0);
-92
View File
@@ -1,92 +0,0 @@
/**
* Локальная разработка: демо для 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);
-81
View File
@@ -1,81 +0,0 @@
/**
* Локальная разработка: демо партнёра услуг (клининг) — заказы у партнёра, сотрудники, доход,
* и одноразовая ссылка на кабинет менеджера в браузере.
* pnpm --filter @barev/api dev:partners-demo
* Клиенты демо — telegramId 100003000+. Повторный запуск пересоздаёт их заказы.
*/
import { fromYerevan, toYerevan } from '@barev/core';
import { getDb } from '@barev/db';
import { createOrder, createProWebLogin, dispatcherAssign, giveConsent, markPaid, offerOrder, startClient, transitionOrder, type Ctx } from '@barev/services';
import { env } from '../src/env.ts';
if (env.isProd) throw new Error('Только для разработки');
const db = getDb();
const ctx: Ctx = { db, notify: { emit: async () => {} }, now: () => new Date() };
const TG_FROM = 100003000n;
const PRO_APP = process.env.PRO_WEB_URL ?? 'http://localhost:5187';
// ── чистим прошлое демо ──
const old = await db.user.findMany({ where: { telegramId: { gte: TG_FROM, lt: TG_FROM + 100n } }, include: { client: true } });
const oldClients = old.map((u) => u.client?.id).filter((x): x is string => !!x);
const oldOrders = (await db.order.findMany({ where: { clientId: { in: oldClients } }, select: { id: true } })).map((o) => o.id);
await db.partnerEarning.deleteMany({ where: { orderId: { in: oldOrders } } });
await db.agencyCommission.deleteMany({ where: { orderId: { in: oldOrders } } });
await db.order.deleteMany({ where: { id: { in: oldOrders } } });
let company = await db.providerCompany.findFirst({ where: { active: true, roles: { has: 'CLEANER' } } });
if (!company) throw new Error('Нет компании-партнёра — запустите seed');
company = await db.providerCompany.update({ where: { id: company.id }, data: { deal: 'PROFIT_SHARE', profitSharePct: 40, fotPct: null, taxPct: null } });
const manager = await db.companyDispatcher.findFirstOrThrow({ where: { companyId: company.id } });
const staff = await db.provider.findMany({ where: { companyId: company.id, active: true } });
// график всем сотрудникам: каждый день 9–20, лимит 3 заказа в день у первого
for (const [i, p] of staff.entries()) {
await db.providerSchedule.deleteMany({ where: { providerId: p.id } });
await db.providerSchedule.createMany({ data: [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ providerId: p.id, weekday, startMinute: 9 * 60, endMinute: 20 * 60 })) });
await db.provider.update({ where: { id: p.id }, data: { maxOrdersPerDay: i === 0 ? 3 : null, maxHoursPerDay: i === 0 ? 8 : null } });
}
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
const admin = await db.adminUser.findFirstOrThrow({ where: { email: 'owner@barev.local' } });
const at = (dayOffset: number, hour: number) => {
const p = toYerevan(new Date(Date.now() + dayOffset * 24 * 3600_000));
return fromYerevan(p.year, p.month, p.day, hour * 60);
};
const NAMES = ['Ольга', 'James', 'Ирина', 'Arpi', 'Лилит'];
async function demoOrder(i: number, when: Date, rooms: number) {
const s = await startClient(ctx, { id: Number(TG_FROM) + i, first_name: NAMES[i % NAMES.length]!, language_code: i % 2 ? 'en' : 'ru' });
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms, bathrooms: 1 }, scheduledAt: when, addressText: `Абовяна ${10 + i}`, districtId: kentron.id, allowBusySlot: true });
}
// 1) два заказа ждут назначения у партнёра
for (const [i, d] of [
[0, 2],
[1, 3],
] as const) {
const o = await demoOrder(i, at(d, 11), 2 + i);
await offerOrder(ctx, { orderId: o.id, adminId: admin.id, target: { kind: 'company', id: company.id } });
}
// 2) один назначен на сотрудника
const assigned = await demoOrder(2, at(2, 15), 1);
await offerOrder(ctx, { orderId: assigned.id, adminId: admin.id, target: { kind: 'company', id: company.id } });
const linked = staff.find((p) => p.userId) ?? staff[0]!;
await dispatcherAssign(ctx, { orderId: assigned.id, companyId: company.id, providerId: linked.id, dispatcherUserId: manager.userId });
// 3) один выполнен и оплачен — доход партнёра
const done = await demoOrder(3, at(1, 10), 3);
await offerOrder(ctx, { orderId: done.id, adminId: admin.id, target: { kind: 'company', id: company.id } });
await dispatcherAssign(ctx, { orderId: done.id, companyId: company.id, providerId: linked.id, dispatcherUserId: manager.userId });
for (const to of ['EN_ROUTE', 'CLEANING'] as const) await transitionOrder(ctx, { orderId: done.id, to, actor: 'ADMIN', actorId: admin.id, event: 'demo' });
await db.orderChecklistItem.updateMany({ where: { orderId: done.id }, data: { checkedAt: new Date() } });
await db.order.update({ where: { id: done.id }, data: { status: 'DONE', completedAt: new Date() } });
await markPaid(ctx, { orderId: done.id, by: { type: 'ADMIN', id: admin.id } });
// 4) новый заказ без исполнителя — оператор выбирает, кому отдать (видно, что выгоднее)
const fresh = await demoOrder(4, at(4, 12), 2);
console.log(`Заказ для назначения: #${fresh.number} (${fresh.id})`);
const code = await createProWebLogin(ctx, manager.userId);
console.log(`Партнёр: ${company.name}`);
console.log(`Кабинет менеджера в браузере (ссылка одноразовая, 15 минут):\n${PRO_APP}/?login=${code}#/company`);
process.exit(0);
-46
View File
@@ -1,46 +0,0 @@
/**
* Локальная разработка: демо бариков у клиента «Анна» (telegramId 100000001):
* начисления (доступные и в ожидании), бонус, списание, открытый запрос и предложение системы.
* pnpm --filter @barev/api dev:points-demo
*/
import { DAY } from '@barev/core';
import { getDb } from '@barev/db';
import { accruePoints, redeemPoints, requestRedemption, type Ctx } from '@barev/services';
import { env } from '../src/env.ts';
if (env.isProd) throw new Error('Только для разработки');
const db = getDb();
const ctx: Ctx = { db, notify: { emit: async () => {} }, now: () => new Date() };
const OWNER = { adminId: null, owner: true };
const user = await db.user.findUniqueOrThrow({ where: { telegramId: 100000001n }, include: { client: true } });
const clientId = user.client!.id;
// чистим прошлое демо этого клиента
await db.pointsRedemptionItem.deleteMany({ where: { redemption: { clientId } } });
await db.pointsRedemption.deleteMany({ where: { clientId } });
await db.pointsReversal.deleteMany({ where: { clientId } });
await db.pointsSuggestion.deleteMany({ where: { clientId } });
await db.pointsAccrual.deleteMany({ where: { clientId } });
await db.client.update({ where: { id: clientId }, data: { pointsActive: 0, pointsPending: 0, pointsDebt: 0, streakCount: 0, streakLastAt: null, pointsActivityAt: null } });
const cat = (code: string) => db.pointsCategory.findUniqueOrThrow({ where: { code } });
const now = Date.now();
const ago = (d: number) => new Date(now - d * DAY);
const cleaning = await cat('cleaning');
for (const [i, d] of [50, 25, 3].entries()) {
await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: cleaning.id, orderAmount: 18_000 + i * 2000, serviceDate: ago(d), orderRef: `DEMO-C${i}`, channel: 'WEB' }, OWNER);
}
await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: (await cat('documents')).id, orderAmount: 45_000, excludedAmount: 15_000, serviceDate: ago(10), orderRef: 'DEMO-D1', channel: 'WEB' }, OWNER);
// ожидание 48 ч от даты услуги: старые уже доступны — активируем
const { activatePendingPoints } = await import('@barev/services');
await activatePendingPoints(ctx);
// свежая уборка — ещё в ожидании
await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: cleaning.id, orderAmount: 19_000, serviceDate: new Date(), orderRef: 'DEMO-C3', channel: 'AUTO' }, OWNER);
const bonus = await db.pointsBonusType.findUniqueOrThrow({ where: { code: 'housewarming' } });
await accruePoints(ctx, { clientId, kind: 'BONUS', bonusTypeId: bonus.id, amount: 5000, serviceDate: new Date(), channel: 'WEB', commentForClient: 'Yerevan Home' }, OWNER);
await redeemPoints(ctx, { clientId, amount: 2000, orderAmount: 20_000, orderRef: 'DEMO-R1', reason: 'оплата по звонку', channel: 'WEB' }, OWNER);
await requestRedemption(ctx, { clientId, amount: 1500, note: 'Уборка 30 октября, 18 000 ֏' });
await db.pointsSuggestion.create({ data: { clientId, mechanic: 'first_month_route', dedupeKey: `route:${clientId}`, payload: { categories: ['cleaning', 'documents', 'learning'] }, suggestedAmount: 3000 } });
const c = await db.client.findUniqueOrThrow({ where: { id: clientId } });
console.log(`Анна: доступно ${c.pointsActive}, в ожидании ${c.pointsPending}, серия ${c.streakCount}`);
process.exit(0);
-137
View File
@@ -1,137 +0,0 @@
/** Этап 5: рассылки клиентам, журнал действий, настройки */
import { DAY, SETTING_KEYS, settingsSchema } from '@barev/core';
import type { Prisma } from '@barev/db';
import { getSettings } from '@barev/services';
import { TelegramSender } from '@barev/services/telegram';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { env } from '../../env.ts';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
import { clientFilters, filteredClients } from './clients.ts';
const LANGS = ['ru', 'en', 'zh', 'hy', 'es'] as const;
const texts = z.object({ ru: z.string().trim().min(1).max(3500), en: z.string().max(3500), zh: z.string().max(3500), hy: z.string().max(3500), es: z.string().max(3500) });
const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** Рассылки делают поддержка и суперадмин */
const bc = rolesProcedure('SUPPORT');
/** Получатели: клиенты по фильтрам, только давшие согласие (иначе бот им не писал) */
async function recipients(db: import('@barev/db').Db, filters: z.infer<typeof clientFilters>, now: Date) {
const { list } = await filteredClients(db, filters, now);
return list.filter((c) => !!c.consentAt).map((c) => ({ id: c.id, locale: c.user.locale }));
}
export const boBroadcastsRouter = router({
/** Сколько клиентов получит и на каких языках */
preview: bc.input(clientFilters).query(async ({ ctx, input }) => {
const list = await recipients(ctx.db, input, ctx.svc.now());
const byLocale = Object.fromEntries(LANGS.map((l) => [l, list.filter((c) => c.locale === l).length])) as Record<(typeof LANGS)[number], number>;
return { total: list.length, byLocale };
}),
/** Проверка: прислать себе в клиентский бот (нужно, чтобы вы хоть раз открыли его) */
test: bc.input(z.object({ text: z.string().trim().min(1).max(3500), appButton: z.boolean() })).mutation(async ({ ctx, input }) => {
if (!ctx.admin.telegramId) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'TELEGRAM_NOT_LINKED' });
const bot = new TelegramSender(env.CLIENT_BOT_TOKEN, 'client-bot');
const app = env.CLIENT_MINIAPP_URL;
const ok = await bot.send({
chatId: ctx.admin.telegramId,
text: escapeHtml(input.text),
buttons: input.appButton && app.startsWith('https://') ? [[{ text: 'Barev Club', web_app: { url: app } }]] : [],
});
if (!ok) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'TEST_UNREACHABLE' });
}),
/** Запустить рассылку: фиксируем получателей, воркер отправляет с ограничением скорости Telegram */
send: bc.input(z.object({ filters: clientFilters, texts, appButton: z.boolean() })).mutation(async ({ ctx, input }) => {
const list = await recipients(ctx.db, input.filters, ctx.svc.now());
if (!list.length) throw new TRPCError({ code: 'BAD_REQUEST', message: 'NO_RECIPIENTS' });
const b = await ctx.db.broadcast.create({
data: {
texts: input.texts,
appButton: input.appButton,
filters: input.filters as Prisma.InputJsonValue,
total: list.length,
createdById: ctx.admin.id,
recipients: { createMany: { data: list.map((c) => ({ clientId: c.id })) } },
},
});
await ctx.svc.notify.emit({ type: 'broadcast.start', broadcastId: b.id });
return { id: b.id, total: list.length };
}),
list: bc.query(async ({ ctx }) => {
const rows = await ctx.db.broadcast.findMany({ orderBy: { createdAt: 'desc' }, take: 50 });
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: rows.map((r) => r.createdById).filter((x): x is string => !!x) } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
return rows.map((r) => ({
id: r.id,
text: (r.texts as Record<string, string>).ru ?? '',
status: r.status,
total: r.total,
sent: r.sent,
blocked: r.blocked,
failed: r.failed,
by: r.createdById ? (admins.get(r.createdById) ?? null) : null,
createdAt: r.createdAt,
finishedAt: r.finishedAt,
}));
}),
cancel: bc.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.broadcast.updateMany({ where: { id: input.id, status: 'SENDING' }, data: { status: 'CANCELLED', finishedAt: ctx.svc.now() } });
}),
});
// ───── журнал действий ─────
export const boJournalRouter = router({
list: superProcedure
.input(z.object({ adminUserId: z.string().optional(), entity: z.string().max(40).optional(), days: z.number().int().min(1).max(365).default(30), cursor: z.string().optional() }))
.query(async ({ ctx, input }) => {
const since = new Date(ctx.svc.now().getTime() - input.days * DAY);
const rows = await ctx.db.auditLog.findMany({
where: { createdAt: { gte: since }, ...(input.adminUserId ? { adminUserId: input.adminUserId } : {}), ...(input.entity ? { entity: input.entity } : {}) },
orderBy: { createdAt: 'desc' },
take: 101,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
include: { adminUser: { select: { id: true, name: true } } },
});
const page = rows.slice(0, 100);
return {
items: page.map((r) => ({ id: r.id, at: r.createdAt, by: r.adminUser, action: r.action, entity: r.entity, entityId: r.entityId, data: r.data as Record<string, unknown> | null })),
next: rows.length > 100 ? page.at(-1)!.id : null,
};
}),
facets: superProcedure.query(async ({ ctx }) => {
const [staff, entities] = await Promise.all([
ctx.db.adminUser.findMany({ orderBy: { name: 'asc' }, select: { id: true, name: true } }),
ctx.db.auditLog.groupBy({ by: ['entity'], _count: true, orderBy: { _count: { entity: 'desc' } } }),
]);
return { staff, entities: entities.map((e) => e.entity) };
}),
});
// ───── настройки ─────
export const boSettingsRouter = router({
get: superProcedure.query(({ ctx }) => getSettings(ctx.db)),
save: superProcedure.input(settingsSchema.partial()).mutation(async ({ ctx, input }) => {
for (const key of SETTING_KEYS) {
const value = input[key];
if (value === undefined) continue;
await ctx.db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
}
}),
paymentMethods: superProcedure.query(({ ctx }) => ctx.db.paymentMethod.findMany({ orderBy: { sortOrder: 'asc' } })),
savePaymentMethod: superProcedure
.input(z.object({ id: z.string().optional(), code: z.string().trim().min(1).max(40), name: texts, active: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
if (id) await ctx.db.paymentMethod.update({ where: { id }, data });
else {
const last = await ctx.db.paymentMethod.findFirst({ orderBy: { sortOrder: 'desc' } });
await ctx.db.paymentMethod.create({ data: { ...data, sortOrder: (last?.sortOrder ?? 0) + 1 } });
}
}),
});
-110
View File
@@ -1,110 +0,0 @@
import {
assignThread,
closeThread,
deleteTemplate,
getThreadForStaff,
listTemplates,
listThreads,
moveThread,
openThreadFor,
reopenThread,
saveTemplate,
staffSend,
threadCounts,
type ChatChannel,
} from '@barev/services';
import type { Db } from '@barev/db';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, type StaffRole } from '../../trpc.ts';
const CHANNELS = ['SUPPORT', 'CONCIERGE'] as const;
/** Какие каналы видит сотрудник: поддержка — SUPPORT, консьерж — CONCIERGE, суперадмин — оба */
export function channelsFor(roles: StaffRole[], isSuper: boolean): ChatChannel[] {
if (isSuper) return ['SUPPORT', 'CONCIERGE'];
return CHANNELS.filter((c) => roles.includes(c));
}
const chatProcedure = rolesProcedure('SUPPORT', 'CONCIERGE').use(async ({ ctx, next }) => next({ ctx: { channels: channelsFor(ctx.roles, ctx.isSuper) } }));
/** Диалог существует и его канал доступен этому сотруднику */
async function guard(ctx: { db: Db; channels: ChatChannel[] }, id: string) {
const t = await ctx.db.chatThread.findUnique({ where: { id } });
if (!t) throw new TRPCError({ code: 'NOT_FOUND' });
if (!ctx.channels.includes(t.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return t;
}
const filter = z.enum(['mine', 'unassigned', 'waiting', 'open', 'closed', 'all']);
export const boChatsRouter = router({
counts: chatProcedure.query(({ ctx }) => threadCounts(ctx.svc, { channels: ctx.channels, adminUserId: ctx.admin.id })),
list: chatProcedure
.input(z.object({ channel: z.enum(CHANNELS).optional(), filter, search: z.string().trim().max(100).optional() }))
.query(({ ctx, input }) => listThreads(ctx.svc, { ...input, channels: ctx.channels, adminUserId: ctx.admin.id })),
get: chatProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
await guard(ctx, input.id);
return getThreadForStaff(ctx.svc, input.id);
}),
send: chatProcedure
.input(z.object({ id: z.string(), text: z.string().max(4000), internal: z.boolean().default(false) }))
.mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
const m = await staffSend(ctx.svc, { threadId: input.id, adminUserId: ctx.admin.id, text: input.text, internal: input.internal });
return { id: m.id };
}),
/** Взять себе (assigneeId = я), передать коллеге или снять ответственного (null) */
assign: chatProcedure.input(z.object({ id: z.string(), assigneeId: z.string().nullable() })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
await assignThread(ctx.svc, { threadId: input.id, assigneeId: input.assigneeId, byAdminId: ctx.admin.id });
}),
close: chatProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
await closeThread(ctx.svc, { threadId: input.id, byAdminId: ctx.admin.id });
}),
reopen: chatProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
await reopenThread(ctx.svc, { threadId: input.id, byAdminId: ctx.admin.id });
}),
/** Перенести в другой канал; диалог остаётся видимым, только если новый канал доступен сотруднику */
move: chatProcedure.input(z.object({ id: z.string(), channel: z.enum(CHANNELS) })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
const t = await moveThread(ctx.svc, { threadId: input.id, channel: input.channel, byAdminId: ctx.admin.id });
return { id: t.id, visible: ctx.channels.includes(t.channel) };
}),
/** Написать клиенту из карточки: открывает диалог в канале, куда он пишет (или в указанном) */
openFor: chatProcedure.input(z.object({ clientId: z.string().optional(), providerId: z.string().optional(), channel: z.enum(CHANNELS).optional() })).mutation(async ({ ctx, input }) => {
if (!input.clientId && !input.providerId) throw new TRPCError({ code: 'BAD_REQUEST' });
// исполнитель пишет только в поддержку
const channel = input.providerId ? 'SUPPORT' : (input.channel ?? (ctx.channels.length === 1 ? ctx.channels[0] : undefined));
const t = await openThreadFor(ctx.svc, input.providerId ? { providerId: input.providerId } : { clientId: input.clientId! }, channel);
if (!ctx.channels.includes(t.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return { id: t.id };
}),
/** Кому можно передать диалог этого канала */
colleagues: chatProcedure.input(z.object({ channel: z.enum(CHANNELS) })).query(async ({ ctx, input }) => {
const staff = await ctx.db.adminUser.findMany({ where: { active: true, roles: { hasSome: ['SUPERADMIN', input.channel] } }, orderBy: { name: 'asc' } });
return staff.map((s) => ({ id: s.id, name: s.name, roles: s.roles, telegramLinked: !!s.telegramId }));
}),
templates: router({
list: chatProcedure.query(({ ctx }) => listTemplates(ctx.svc, ctx.channels)),
save: chatProcedure
.input(z.object({ id: z.string().optional(), channel: z.enum(CHANNELS).nullable(), title: z.string().trim().min(1).max(80), text: z.string().trim().min(1).max(4000) }))
.mutation(async ({ ctx, input }) => {
if (input.channel && !ctx.channels.includes(input.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return saveTemplate(ctx.svc, { ...input, adminUserId: ctx.admin.id });
}),
remove: chatProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => deleteTemplate(ctx.svc, input.id)),
}),
});
+1 -1
View File
@@ -110,7 +110,7 @@ function baseWhere(f: ClientFilters): Prisma.ClientWhereInput {
return and.length ? { AND: and } : {};
}
export async function filteredClients(db: Db, f: ClientFilters, now: Date) {
async function filteredClients(db: Db, f: ClientFilters, now: Date) {
const rows = await db.client.findMany({ where: baseWhere(f), include: clientInclude, orderBy: { createdAt: 'desc' }, take: 5000 });
const money = await revenueByClient(
db,
-257
View File
@@ -1,257 +0,0 @@
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 } });
}),
});
+2 -4
View File
@@ -1,6 +1,5 @@
import { DAY, startOfYerevanDay } from '@barev/core';
import { threadCounts } from '@barev/services';
import { channelsFor } from './chats.ts';
import { unreadConciergeCount } from '@barev/services';
import { router, staffProcedure } from '../../trpc.ts';
/** День по Еревану (UTC+4, без перехода на летнее время) */
@@ -17,8 +16,7 @@ export const boDashboardRouter = router({
const now = ctx.svc.now();
const [unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring] = await Promise.all([
ctx.db.order.count({ where: { providerId: null, companyId: null, status: { in: ['NEW', 'ENROLLED'] }, offers: { none: { status: 'OFFERED' } } } }),
// диалоги, которые ждут ответа, — только в каналах, доступных сотруднику
threadCounts(ctx.svc, { channels: channelsFor(ctx.roles, ctx.isSuper), adminUserId: ctx.admin.id }).then((c) => c.total),
unreadConciergeCount(ctx.svc),
ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } }),
ctx.db.lead.count({ where: { status: 'NEW' } }),
ctx.db.clientRequest.count({ where: { status: 'NEW' } }),
-21
View File
@@ -2,15 +2,6 @@ import { router } from '../../trpc.ts';
import { boAuthRouter, boTeamRouter } from './auth.ts';
import { boClientsRouter, boSegmentsRouter } from './clients.ts';
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';
import { boPointsRouter } from './points.ts';
import { boProductsRouter } from './products.ts';
import { boMovesRouter, boRequestsRouter } from './moves.ts';
import { boBroadcastsRouter, boJournalRouter, boSettingsRouter } from './admin5.ts';
/** API новой админки (apps/backoffice) */
export const backofficeRouter = router({
@@ -19,16 +10,4 @@ export const backofficeRouter = router({
dashboard: boDashboardRouter,
clients: boClientsRouter,
segments: boSegmentsRouter,
chats: boChatsRouter,
orders: boOrdersRouter,
moves: boMovesRouter,
requests: boRequestsRouter,
providers: boProvidersRouter,
crm: boCrmRouter,
partners: boPartnersRouter,
points: boPointsRouter,
products: boProductsRouter,
broadcasts: boBroadcastsRouter,
journal: boJournalRouter,
settings: boSettingsRouter,
});
-136
View File
@@ -1,136 +0,0 @@
import {
addLeadDoc,
addLeadStep,
grantSubscription,
listClientRequests,
listLeads,
listRelocationItems,
listWebRequests,
relocationNames,
removeLeadDoc,
removeLeadStep,
updateClientRequest,
updateLeadByAdmin,
updateLeadDoc,
updateLeadStep,
updateWebRequest,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router } from '../../trpc.ts';
/** Переезды и заявки ведут поддержка и консьерж */
const opsProcedure = rolesProcedure('SUPPORT', 'CONCIERGE');
const LEAD_STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
const STEP_STATUSES = ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED'] as const;
const leadInclude = {
steps: { orderBy: { sortOrder: 'asc' } },
docs: { orderBy: { sortOrder: 'asc' } },
client: { select: { id: true, cardCode: true, user: { select: { firstName: true, lastName: true, username: true, phone: true } } } },
} as const;
/** Переезды: заявки из квиза на сайте и из приложения, у каждой — план по шагам и документы клиента */
export const boMovesRouter = router({
counts: opsProcedure.query(async ({ ctx }) => {
const rows = await ctx.db.lead.groupBy({ by: ['status'], where: { OR: [{ moved: null }, { moved: false }] }, _count: true });
const out = { NEW: 0, IN_WORK: 0, DONE: 0, CANCELLED: 0 };
for (const r of rows) out[r.status] = r._count;
return out;
}),
list: opsProcedure.input(z.object({ status: z.enum(LEAD_STATUSES).optional(), search: z.string().max(100).optional() })).query(async ({ ctx, input }) => {
const leads = await listLeads(ctx.svc, { status: input.status });
const q = input.search?.trim().toLowerCase().replace(/^#/, '');
return leads
.filter((l) => !l.moved)
.filter((l) => !q || String(l.number) === q || l.name.toLowerCase().includes(q) || l.contact.toLowerCase().includes(q) || (l.agency ?? '').toLowerCase().includes(q))
.map((l) => {
const steps = l.steps.filter((s) => s.status !== 'SKIPPED');
return {
id: l.id,
number: l.number,
name: l.name,
contact: l.contact,
contactType: l.contactType,
locale: l.locale,
agency: l.agency,
status: l.status,
package: l.package,
moveWhen: l.moveWhen,
moveDate: l.moveDate,
submitted: !!l.submittedAt,
createdAt: l.createdAt,
inApp: !!l.client,
stepsDone: steps.filter((s) => s.status === 'DONE').length,
stepsTotal: steps.length,
current: steps.find((s) => s.status === 'IN_PROGRESS') ?? steps.find((s) => s.status === 'TODO') ?? null,
docsDone: l.docs.filter((d) => d.checked).length,
docsTotal: l.docs.length,
adminNote: l.adminNote,
};
});
}),
get: opsProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const lead = await ctx.db.lead.findUnique({ where: { id: input.id }, omit: { key: true }, include: leadInclude });
if (!lead) throw new TRPCError({ code: 'NOT_FOUND' });
return lead;
}),
/** общий список шагов и документов: названия на 5 языках */
names: opsProcedure.query(({ ctx }) => relocationNames(ctx.db)),
templates: opsProcedure.query(({ ctx }) => listRelocationItems(ctx.svc)),
update: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(LEAD_STATUSES).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateLeadByAdmin(ctx.svc, input)),
/** шаг плана: статус (клиенту уходит сообщение в бота), комментарий для клиента, своё название */
updateStep: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(STEP_STATUSES).optional(), note: z.string().max(500).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => updateLeadStep(ctx.svc, { stepId: input.id, status: input.status, note: input.note, title: input.title })),
addStep: opsProcedure
.input(z.object({ id: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => addLeadStep(ctx.svc, { leadId: input.id, code: input.code, title: input.title })),
removeStep: opsProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => removeLeadStep(ctx.svc, input.id)),
addDoc: opsProcedure
.input(z.object({ id: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => addLeadDoc(ctx.svc, { leadId: input.id, code: input.code, title: input.title })),
updateDoc: opsProcedure
.input(z.object({ id: z.string(), title: z.string().max(120).optional(), checked: z.boolean().optional() }))
.mutation(({ ctx, input }) => updateLeadDoc(ctx.svc, { docId: input.id, title: input.title, checked: input.checked })),
removeDoc: opsProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => removeLeadDoc(ctx.svc, input.id)),
});
/** Заявки: с сайта (уборка без регистрации) и из приложения (подписка, «перезвоните» по услуге) */
export const boRequestsRouter = router({
counts: opsProcedure.query(async ({ ctx }) => {
const [site, app] = await Promise.all([
ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } }),
ctx.db.clientRequest.count({ where: { status: 'NEW' } }),
]);
return { site, app };
}),
site: opsProcedure.input(z.object({ open: z.boolean() })).query(async ({ ctx, input }) => {
const rows = await listWebRequests(ctx.svc, {});
return rows.filter((r) => (input.open ? r.status === 'NEW' || r.status === 'LINKED' : r.status === 'ORDERED' || r.status === 'CANCELLED'));
}),
app: opsProcedure.input(z.object({ open: z.boolean() })).query(async ({ ctx, input }) => {
const rows = await listClientRequests(ctx.svc, {});
return rows.filter((r) => (input.open ? r.status === 'NEW' : r.status !== 'NEW'));
}),
updateSite: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(['CANCELLED', 'REOPEN']).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateWebRequest(ctx.svc, input)),
updateApp: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(['NEW', 'DONE', 'CANCELLED']).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateClientRequest(ctx.svc, input)),
/** включить подписку по заявке из приложения: создаёт подписку и закрывает заявку */
activate: opsProcedure.input(z.object({ id: z.string(), days: z.number().int().min(1).max(366) })).mutation(async ({ ctx, input }) => {
const r = await ctx.db.clientRequest.findUniqueOrThrow({ where: { id: input.id } });
if (r.kind !== 'SUBSCRIPTION' || !r.plan) throw new TRPCError({ code: 'BAD_REQUEST' });
return grantSubscription(ctx.svc, { clientId: r.clientId, plan: r.plan, days: input.days, requestId: r.id });
}),
});
-420
View File
@@ -1,420 +0,0 @@
import { DAY, getFlow, kanbanColumn, ORDER_STATUSES, startOfYerevanDay, type ServiceFlow } from '@barev/core';
import type { Db, Prisma } from '@barev/db';
import {
assignmentCandidates,
autoAssign,
availableSlots,
breakdownOf,
createOrder,
getServiceByCode,
getSettings,
markPaid,
previewOrderEdit,
editOrderComposition,
staffChecklistItem,
offerOrder,
orderPayout,
quoteForClient,
rescheduleByAdmin,
transitionOrder,
unassignOrder,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/** Заказы ведёт поддержка (диспетчер); суперадмину — всё */
const ordersProcedure = rolesProcedure('SUPPORT');
/** Группы списка: ждёт исполнителя · предложен · назначен · в работе · готово · отменён */
export const ORDER_GROUPS = ['new', 'pending', 'assigned', 'in_progress', 'done', 'cancelled'] as const;
type Group = (typeof ORDER_GROUPS)[number];
const name = (u: { firstName: string | null; lastName: string | null; username: string | null }) =>
[u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
const listInclude = {
service: { select: { code: true, flow: true, category: { select: { code: true } } } },
district: { select: { code: true } },
provider: { select: { id: true, name: true } },
company: { select: { id: true, name: true } },
client: { include: { user: { select: { firstName: true, lastName: true, username: true } } } },
offers: { where: { status: 'OFFERED' as const }, include: { provider: { select: { name: true } }, company: { select: { name: true } } } },
rating: { select: { score: true } },
history: { where: { event: { in: ['autoNoCandidate', 'autoGaveUp', 'autoOffered', 'offered', 'assignedToCompany'] } }, orderBy: { createdAt: 'desc' as const }, take: 1 },
} satisfies Prisma.OrderInclude;
type ListRow = Prisma.OrderGetPayload<{ include: typeof listInclude }>;
function groupOf(o: ListRow): Group {
const col = kanbanColumn(o.service.flow as ServiceFlow, o.status, { hasProvider: !!o.providerId, hasPendingOffer: o.offers.length > 0, hasCompany: !!o.companyId });
if (col === 'assigned' && !o.providerId) return 'pending';
return col === 'new' ? 'new' : col;
}
function rowView(o: ListRow, showMoney: boolean) {
const offer = o.offers[0];
const lastAuto = o.history[0]?.event ?? null;
return {
id: o.id,
number: o.number,
service: o.service.code,
category: o.service.category.code,
flow: o.service.flow,
status: o.status,
group: groupOf(o),
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
district: o.district?.code ?? null,
address: o.addressText,
isOnline: o.isOnline,
client: { id: o.clientId, name: name(o.client.user) },
provider: o.provider,
company: o.company,
/** ждёт ответа: кому предложено и до какого времени; auto — предложила система */
pending: offer ? { name: offer.provider?.name ?? offer.company?.name ?? '', expiresAt: offer.expiresAt, auto: !offer.createdById, company: !offer.providerId } : null,
/** автораспределение не нашло исполнителя — нужен человек */
needsDispatcher: !o.providerId && !offer && (lastAuto === 'autoNoCandidate' || lastAuto === 'autoGaveUp'),
total: showMoney ? o.totalPrice : null,
paymentStatus: o.paymentStatus,
rating: o.rating?.score ?? null,
};
}
const periodInput = z.enum(['today', 'tomorrow', 'week', 'past', 'all']);
function periodWhere(period: z.infer<typeof periodInput>, now: Date): Prisma.OrderWhereInput {
const today = startOfYerevanDay(now);
switch (period) {
case 'today':
return { scheduledAt: { gte: today, lt: new Date(today.getTime() + DAY) } };
case 'tomorrow':
return { scheduledAt: { gte: new Date(today.getTime() + DAY), lt: new Date(today.getTime() + 2 * DAY) } };
case 'week':
return { scheduledAt: { gte: today, lt: new Date(today.getTime() + 7 * DAY) } };
case 'past':
return { scheduledAt: { lt: today } };
default:
return {};
}
}
function searchWhere(q: string | undefined): Prisma.OrderWhereInput {
const s = q?.trim();
if (!s) return {};
const n = Number(s.replace(/^[#№]/, ''));
return {
OR: [
...(Number.isInteger(n) && n > 0 ? [{ number: n }] : []),
{ addressText: { contains: s, mode: 'insensitive' as const } },
{ client: { user: { OR: [{ firstName: { contains: s, mode: 'insensitive' as const } }, { lastName: { contains: s, mode: 'insensitive' as const } }, { username: { contains: s.replace(/^@/, ''), mode: 'insensitive' as const } }, { phone: { contains: s } }] } } },
{ provider: { name: { contains: s, mode: 'insensitive' as const } } },
],
};
}
/** Активные заказы и закрытые за последние 7 дней — из них считаем группы */
async function loadRows(db: Db, where: Prisma.OrderWhereInput, now: Date) {
const weekAgo = new Date(now.getTime() - 7 * DAY);
return db.order.findMany({
where: { AND: [where, { OR: [{ status: { notIn: ['DONE', 'COMPLETED', 'PLAN_SENT', 'CANCELLED', 'LESSON_DONE'] } }, { updatedAt: { gte: weekAgo } }, { scheduledAt: { gte: weekAgo } }] }] },
include: listInclude,
orderBy: { scheduledAt: 'asc' },
take: 600,
});
}
/** История заказа понятными словами: кто (имя сотрудника / исполнителя / клиента / система) */
async function historyView(db: Db, o: { client: { user: { firstName: string | null; lastName: string | null; username: string | null } }; history: { id: string; fromStatus: string | null; toStatus: string; event: string | null; actorType: string; actorId: string | null; comment: string | null; createdAt: Date }[] }) {
const ids = (t: string) => [...new Set(o.history.filter((h) => h.actorType === t && h.actorId).map((h) => h.actorId!))];
const [admins, providers, dispatchers] = await Promise.all([
db.adminUser.findMany({ where: { id: { in: ids('ADMIN') } }, select: { id: true, name: true } }),
db.provider.findMany({ where: { id: { in: [...ids('PROVIDER'), ...ids('SYSTEM')] } }, select: { id: true, name: true } }),
db.user.findMany({ where: { id: { in: ids('DISPATCHER') } }, select: { id: true, firstName: true, lastName: true, username: true } }),
]);
const who = new Map<string, string>([...admins.map((a) => [a.id, a.name] as const), ...providers.map((p) => [p.id, p.name] as const), ...dispatchers.map((u) => [u.id, name(u)] as const)]);
return o.history.map((h) => ({
id: h.id,
from: h.fromStatus,
to: h.toStatus,
event: h.event,
actor: h.actorType,
actorName: h.actorType === 'CLIENT' ? name(o.client.user) : h.actorId ? (who.get(h.actorId) ?? null) : null,
comment: h.comment,
at: h.createdAt,
}));
}
const idInput = z.object({ id: z.string() });
export const boOrdersRouter = router({
/** Счётчики групп (без учёта фильтра группы) */
counts: ordersProcedure.input(z.object({ period: periodInput.default('all'), search: z.string().max(100).optional() }).optional()).query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const rows = await loadRows(ctx.db, { ...periodWhere(input?.period ?? 'all', now), ...searchWhere(input?.search) }, now);
const counts = Object.fromEntries(ORDER_GROUPS.map((g) => [g, 0])) as Record<Group, number>;
let needsDispatcher = 0;
for (const r of rows) {
const v = rowView(r, false);
counts[v.group]++;
if (v.needsDispatcher) needsDispatcher++;
}
return { ...counts, needsDispatcher };
}),
list: ordersProcedure
.input(
z.object({
group: z.enum([...ORDER_GROUPS, 'active', 'all']).default('active'),
period: periodInput.default('all'),
search: z.string().max(100).optional(),
service: z.string().max(40).optional(),
providerId: z.string().optional(),
clientId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const where: Prisma.OrderWhereInput = {
...periodWhere(input.period, now),
...searchWhere(input.search),
...(input.service ? { service: { code: input.service } } : {}),
...(input.providerId ? { providerId: input.providerId } : {}),
...(input.clientId ? { clientId: input.clientId } : {}),
};
const rows = (await loadRows(ctx.db, where, now)).map((r) => rowView(r, ctx.isSuper));
const filtered = rows.filter((r) => (input.group === 'all' ? true : input.group === 'active' ? !['done', 'cancelled'].includes(r.group) : r.group === input.group));
// закрытые — свежие сверху; активные — ближайшие сверху
if (input.group === 'done' || input.group === 'cancelled') filtered.reverse();
return filtered;
}),
get: ordersProcedure.input(idInput).query(async ({ ctx, input }) => {
const o = await ctx.db.order.findUnique({
where: { id: input.id },
include: {
service: { include: { category: true } },
district: true,
provider: { include: { company: true, payoutPreset: true, user: { select: { username: true } } } },
company: true,
client: { include: { user: true, subscriptions: { where: { status: 'ACTIVE' } } } },
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
offers: { orderBy: { createdAt: 'desc' }, include: { provider: { select: { id: true, name: true, phone: true } }, company: { select: { name: true } } } },
files: { where: { deletedAt: null }, select: { id: true, kind: true, mime: true, createdAt: true } },
paymentMethod: true,
rating: true,
},
});
if (!o) throw new TRPCError({ code: 'NOT_FOUND' });
const flow = getFlow(o.service.flow);
const now = ctx.svc.now();
// «Назначен» — только через предложение исполнителю; «Новый» — через «Снять исполнителя»
const adminTransitions = [...new Set(flow.transitions.filter((t) => t.from === o.status && t.by.includes('ADMIN') && t.to !== flow.initial && t.to !== flow.assigned).map((t) => t.to))];
const pending = o.offers.find((x) => x.status === 'OFFERED');
const adminIds = [...new Set(o.offers.map((x) => x.createdById).filter((x): x is string => !!x))];
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: adminIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
const payout = ctx.isSuper && o.provider ? orderPayout(o, o.provider, { fotPct: (await getSettings(ctx.db))['partner.fotPct'] }) : null;
const sub = o.client.subscriptions.find((s) => s.startsAt <= now && s.endsAt > now);
return {
id: o.id,
number: o.number,
service: o.service.code,
category: o.service.category.code,
flow: o.service.flow,
status: o.status,
steps: flow.steps,
group: kanbanColumn(o.service.flow as ServiceFlow, o.status, { hasProvider: !!o.providerId, hasPendingOffer: !!pending, hasCompany: !!o.companyId }),
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
params: o.params as Record<string, unknown>,
isOnline: o.isOnline,
district: o.district?.code ?? null,
address: o.addressText,
note: o.note,
locale: o.locale,
createdAt: o.createdAt,
client: { id: o.clientId, name: name(o.client.user), username: o.client.user.username, phone: o.client.user.phone, plan: sub?.plan ?? null },
provider: o.provider ? { id: o.provider.id, name: o.provider.name, phone: o.provider.phone, username: o.provider.user?.username ?? null } : null,
company: o.company ? { id: o.company.id, name: o.company.name } : null,
pending: pending
? { id: pending.id, name: pending.provider?.name ?? pending.company?.name ?? '', providerId: pending.provider?.id ?? null, phone: pending.provider?.phone ?? null, expiresAt: pending.expiresAt, auto: !pending.createdById, company: !pending.providerId }
: null,
offers: o.offers.map((x) => ({
id: x.id,
name: x.provider?.name ?? x.company?.name ?? '',
company: !x.providerId,
status: x.status,
by: x.createdById ? (admins.get(x.createdById) ?? null) : null,
createdAt: x.createdAt,
respondedAt: x.respondedAt,
})),
history: await historyView(ctx.db, o),
checklist: o.checklist.map((c) => ({ code: c.code, done: !!c.checkedAt, requiresPhoto: c.requiresPhoto, fileId: c.fileId, waived: c.photoWaivedReason, byStaff: !!c.checkedByAdminId })),
photos: o.files.filter((f) => f.kind === 'PHOTO_REPORT').map((f) => f.id),
rating: o.rating ? { score: o.rating.score, comment: o.rating.comment } : null,
// деньги — только суперадмину; статус оплаты видят все (поддержка отмечает оплату наличными)
money: ctx.isSuper
? { base: o.basePrice, discountPct: o.discountPct, discount: o.discountAmount, total: o.totalPrice, cancelFee: o.cancelFee, payout, breakdown: breakdownOf(o) }
: null,
paymentStatus: o.paymentStatus,
paidAt: o.paidAt,
paymentMethod: o.paymentMethod ? (o.paymentMethod.name as Record<string, string>) : null,
cancelReason: o.cancelReason,
adminTransitions,
canAssign: o.status === flow.initial && !o.providerId,
canUnassign: (o.status === flow.initial && (!!pending || !!o.companyId)) || (o.status === flow.assigned && !!o.providerId),
canReschedule: o.status === flow.initial || o.status === flow.assigned,
canCancel: o.status !== 'CANCELLED' && !flow.done.includes(o.status),
};
}),
/** Подсказка: свободные кандидаты по рейтингу подбора и занятые */
candidates: ordersProcedure.input(idInput).query(async ({ ctx, input }) => {
const { ranked, busy, bestId } = await assignmentCandidates(ctx.svc, input.id);
const tried = new Set((await ctx.db.orderOffer.findMany({ where: { orderId: input.id }, select: { providerId: true, companyId: true, status: true } })).filter((o) => o.status !== 'OFFERED').map((o) => o.providerId ?? o.companyId));
const view = (c: (typeof ranked)[number] | (typeof busy)[number]) => ({
kind: c.kind,
id: c.id,
name: c.name,
languages: c.languages,
rating: c.rating,
load: c.load,
onShift: c.onShift,
reasons: 'reasons' in c ? c.reasons : [],
tried: tried.has(c.id),
/** самый выгодный для нас из доступных — подсвечивается всем; суммы видит только суперадмин */
best: `${c.kind}:${c.id}` === bestId,
cost: ctx.isSuper ? c.cost : null,
margin: ctx.isSuper ? c.margin : null,
partnerFree: c.partnerFree ?? null,
partnerStaff: c.partnerStaff ?? null,
});
return { free: ranked.map(view), busy: busy.map(view) };
}),
/** Предложить конкретному исполнителю или компании (текущее предложение отзывается) */
assign: ordersProcedure
.input(z.object({ id: z.string(), target: z.object({ kind: z.enum(['provider', 'company']), id: z.string() }) }))
.mutation(({ ctx, input }) => offerOrder(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, target: input.target }).then((o) => ({ offerId: o.id }))),
/** «Подобрать автоматически» — следующий лучший, даже если автораспределение выключено */
auto: ordersProcedure.input(idInput).mutation(({ ctx, input }) => autoAssign(ctx.svc, input.id, { force: true })),
/** Снять исполнителя / отозвать предложение — заказ снова ждёт исполнителя */
unassign: ordersProcedure.input(idInput).mutation(({ ctx, input }) => unassignOrder(ctx.svc, { orderId: input.id, adminId: ctx.admin.id })),
/** Сменить статус вручную — те же правила, что у исполнителя */
transition: ordersProcedure
.input(z.object({ id: z.string(), to: z.enum(ORDER_STATUSES), comment: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
await transitionOrder(ctx.svc, { orderId: input.id, to: input.to, actor: 'ADMIN', actorId: ctx.admin.id, comment: input.comment });
}),
/** Отмена командой; удержание — вручную (по умолчанию 0) */
cancel: ordersProcedure
.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500), fee: z.number().int().min(0).default(0) }))
.mutation(async ({ ctx, input }) => {
await transitionOrder(ctx.svc, { orderId: input.id, to: 'CANCELLED', actor: 'ADMIN', actorId: ctx.admin.id, comment: input.reason, data: { cancelFee: input.fee, cancelReason: input.reason } });
await ctx.db.orderOffer.updateMany({ where: { orderId: input.id, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: new Date() } });
}),
reschedule: ordersProcedure
.input(z.object({ id: z.string(), scheduledAt: z.date(), allowBusySlot: z.boolean().default(false), comment: z.string().max(500).optional() }))
.mutation(({ ctx, input }) => rescheduleByAdmin(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, scheduledAt: input.scheduledAt, allowBusySlot: input.allowBusySlot, comment: input.comment })),
markPaid: ordersProcedure
.input(z.object({ id: z.string(), paymentMethodId: z.string().optional() }))
.mutation(({ ctx, input }) => markPaid(ctx.svc, { orderId: input.id, by: { type: 'ADMIN', id: ctx.admin.id }, paymentMethodId: input.paymentMethodId })),
// ───── новый заказ за клиента ─────
/** Услуги, которые можно заказать сейчас, с формой параметров */
/** Изменить состав заказа: предпросмотр и сохранение (пересчёт цены или своя цена с причиной) */
editPreview: ordersProcedure.input(z.object({ id: z.string(), params: z.record(z.string(), z.unknown()) })).query(({ ctx, input }) => previewOrderEdit(ctx.svc, { orderId: input.id, params: input.params })),
edit: ordersProcedure
.input(z.object({ id: z.string(), params: z.record(z.string(), z.unknown()), totalPrice: z.number().int().min(0).nullable().optional(), reason: z.string().trim().max(500).nullable().optional(), notify: z.boolean().default(false) }))
.mutation(({ ctx, input }) => editOrderComposition(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, params: input.params, totalPrice: input.totalPrice, reason: input.reason, notify: input.notify })),
/** Чек-лист за исполнителя */
checklist: ordersProcedure
.input(z.object({ id: z.string(), code: z.string().max(60), checked: z.boolean(), reason: z.string().trim().max(500).nullable().optional() }))
.mutation(({ ctx, input }) => staffChecklistItem(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, code: input.code, checked: input.checked, reason: input.reason })),
services: ordersProcedure.query(async ({ ctx }) => {
const list = await ctx.db.service.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' }, select: { code: true } });
return Promise.all(
list.map(async ({ code }) => {
const s = await getServiceByCode(ctx.db, code);
return { code: s.code, category: s.category, isOnline: s.isOnline, paramsSchema: s.paramsSchema };
}),
);
}),
dictionaries: ordersProcedure.query(async ({ ctx }) => ({
districts: (await ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } })).map((d) => ({ id: d.id, code: d.code })),
paymentMethods: (await ctx.db.paymentMethod.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' } })).map((m) => ({ id: m.id, code: m.code, name: m.name as Record<string, string> })),
})),
/** Цена и длительность для клиента (с его скидкой по подписке) */
quote: ordersProcedure
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()), promoCode: z.string().trim().max(40).optional() }))
.query(async ({ ctx, input }) => {
const { quote: q, membership, promo } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params, input.promoCode);
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level, promo: promo ? promo.code : null };
}),
slots: ordersProcedure
.input(z.object({ service: z.string(), params: z.record(z.string(), z.unknown()), dateKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), excludeOrderId: z.string().optional() }))
.query(async ({ ctx, input }) => (await availableSlots(ctx.svc, { serviceCode: input.service, params: input.params, dateKey: input.dateKey, excludeOrderId: input.excludeOrderId })).map((s) => ({ start: s.start, capacity: s.capacity }))),
create: ordersProcedure
.input(
z.object({
clientId: z.string(),
service: z.string(),
params: z.record(z.string(), z.unknown()),
scheduledAt: z.date(),
addressText: z.string().trim().max(300).optional(),
districtId: z.string().optional(),
note: z.string().trim().max(1000).optional(),
paymentMethodId: z.string().optional(),
allowBusySlot: z.boolean().default(false),
promoCode: z.string().trim().max(40).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const o = await createOrder(ctx.svc, {
promoCode: input.promoCode,
clientId: input.clientId,
serviceCode: input.service,
params: input.params,
scheduledAt: input.scheduledAt,
addressText: input.addressText,
districtId: input.districtId,
note: input.note,
paymentMethodId: input.paymentMethodId,
allowBusySlot: input.allowBusySlot,
createdBy: { type: 'ADMIN', id: ctx.admin.id },
});
return { id: o.id, number: o.number };
}),
// ───── автораспределение ─────
autoSetting: ordersProcedure.query(async ({ ctx }) => {
const s = await getSettings(ctx.db);
return { auto: s['assign.auto'], maxTries: s['assign.maxAutoOffers'], timeoutMinutes: s['offer.acceptTimeoutMinutes'] };
}),
setAutoSetting: superProcedure
.input(z.object({ auto: z.boolean().optional(), maxTries: z.number().int().min(1).max(20).optional(), timeoutMinutes: z.number().int().min(1).max(1440).optional() }))
.mutation(async ({ ctx, input }) => {
const pairs: [string, unknown][] = [
['assign.auto', input.auto],
['assign.maxAutoOffers', input.maxTries],
['offer.acceptTimeoutMinutes', input.timeoutMinutes],
];
for (const [key, value] of pairs) {
if (value === undefined) continue;
await ctx.db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
}
}),
});
-350
View File
@@ -1,350 +0,0 @@
import { DAY, normalizeAgencyCode } from '@barev/core';
import { agencyLink, createOwnerInvite, getSettings, managerInviteCode, partnerMoney, savePartnerOffer, termsOf } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { router, superProcedure } from '../../trpc.ts';
/** Партнёры (агентства и заведения) — деньги и условия, поэтому только суперадмин */
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,
giftPlan: a.giftPlan,
giftMonths: a.giftMonths,
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,
giftPlan: a.giftPlan,
giftMonths: a.giftMonths,
housewarmingPoints: a.housewarmingPoints,
housewarmingDays: a.housewarmingDays,
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), giftPlan: z.enum(['BAREV_PLUS', 'ELITE']).nullable(), giftMonths: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(6), z.literal(12)]), housewarmingPoints: z.number().int().min(0).max(1_000_000).default(0), housewarmingDays: z.number().int().min(1).max(1000).default(60), contact: z.string().max(200).nullable(), note: z.string().max(2000).nullable(), active: z.boolean(), salesId: z.string().nullable() }))
.mutation(async ({ ctx, input }) => {
const code = normalizeAgencyCode(input.code);
if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' });
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 } });
}),
// ───── партнёры услуг (клининговые компании) ─────
servicePartners: p.query(async ({ ctx }) => {
const settings = await getSettings(ctx.db);
const list = await ctx.db.providerCompany.findMany({
orderBy: [{ active: 'desc' }, { name: 'asc' }],
include: { _count: { select: { employees: { where: { active: true } }, dispatchers: true } } },
});
const ids = list.map((c) => c.id);
const now = ctx.svc.now();
const [active, month, accrued, paid] = await Promise.all([
ctx.db.order.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED'] } }, _count: true }),
ctx.db.partnerEarning.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, period: monthKey(now) }, _sum: { amount: true } }),
ctx.db.partnerEarning.groupBy({ by: ['companyId'], where: { companyId: { in: ids } }, _sum: { amount: true } }),
ctx.db.payout.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, status: 'PAID' }, _sum: { amount: true } }),
]);
const m = <T extends { companyId: string | null }>(rows: T[], f: (r: T) => number) => new Map(rows.map((r) => [r.companyId, f(r)]));
const act = m(active, (r) => r._count);
const mon = m(month, (r) => r._sum.amount ?? 0);
const acc = m(accrued, (r) => r._sum.amount ?? 0);
const pd = m(paid, (r) => r._sum.amount ?? 0);
return list.map((c) => ({
id: c.id,
name: c.name,
active: c.active,
deal: c.deal,
terms: termsOf(c, settings),
autoAssign: c.autoAssign,
staff: c._count.employees,
managers: c._count.dispatchers,
activeOrders: act.get(c.id) ?? 0,
thisMonth: mon.get(c.id) ?? 0,
balance: (acc.get(c.id) ?? 0) - (pd.get(c.id) ?? 0),
}));
}),
servicePartner: p.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const c = await ctx.db.providerCompany.findUnique({
where: { id: input.id },
include: { employees: { orderBy: [{ active: 'desc' }, { name: 'asc' }] }, dispatchers: { include: { user: true }, orderBy: { createdAt: 'asc' } } },
});
if (!c) throw new TRPCError({ code: 'NOT_FOUND' });
const settings = await getSettings(ctx.db);
const [money, activeOrders] = await Promise.all([
partnerMoney(ctx.db, c.id),
ctx.db.order.findMany({
where: { companyId: c.id, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED'] } },
orderBy: { scheduledAt: 'asc' },
take: 30,
include: { service: { select: { code: true } }, provider: { select: { name: true } } },
}),
]);
return {
id: c.id,
name: c.name,
contactPhone: c.contactPhone,
note: c.note,
active: c.active,
deal: c.deal,
commissionPct: c.commissionPct,
profitSharePct: c.profitSharePct,
fotPct: c.fotPct,
taxPct: c.taxPct,
terms: termsOf(c, settings),
defaults: { fotPct: settings['partner.fotPct'], taxPct: settings['partner.taxPct'] },
autoAssign: c.autoAssign,
travelGapMinutes: c.travelGapMinutes,
createdAt: c.createdAt,
managerLink: proBotUrl(c.inviteCode),
managers: c.dispatchers.map((d) => ({ id: d.id, name: name(d.user), since: d.createdAt })),
staff: c.employees.map((e) => ({ id: e.id, name: e.name, phone: e.phone, active: e.active, linked: !!e.userId, maxOrdersPerDay: e.maxOrdersPerDay, maxHoursPerDay: e.maxHoursPerDay })),
orders: activeOrders.map((o) => ({ id: o.id, number: o.number, service: o.service.code, scheduledAt: o.scheduledAt, status: o.status, staff: o.provider?.name ?? null })),
money,
};
}),
saveServicePartner: p
.input(
z.object({
id: z.string().optional(),
name: z.string().trim().min(1).max(100),
contactPhone: z.string().trim().max(30).nullable(),
note: z.string().max(2000).nullable(),
active: z.boolean(),
deal: z.enum(['PROFIT_SHARE', 'COMMISSION']),
commissionPct: z.number().int().min(0).max(100),
profitSharePct: z.number().int().min(0).max(100),
fotPct: z.number().int().min(0).max(100).nullable(),
taxPct: z.number().int().min(0).max(100).nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
// пока партнёры услуг — только клининг
const c = id
? await ctx.db.providerCompany.update({ where: { id }, data })
: await ctx.db.providerCompany.create({ data: { ...data, roles: ['CLEANER'], inviteCode: managerInviteCode() } });
return { id: c.id };
}),
/** Новая ссылка для менеджеров — старая перестаёт работать (уже подключённые остаются) */
resetManagerLink: p.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const c = await ctx.db.providerCompany.update({ where: { id: input.id }, data: { inviteCode: managerInviteCode() } });
return { link: proBotUrl(c.inviteCode) };
}),
removeManager: p.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.companyDispatcher.delete({ where: { id: input.id } });
}),
servicePartnerPayout: p.input(z.object({ id: z.string(), amount: z.number().int().min(1), note: z.string().max(300).optional() })).mutation(async ({ ctx, input }) => {
const now = ctx.svc.now();
const last = await ctx.db.payout.findFirst({ where: { companyId: input.id }, orderBy: { periodEnd: 'desc' } });
await ctx.db.payout.create({ data: { companyId: input.id, amount: input.amount, status: 'PAID', paidAt: now, note: input.note, createdById: ctx.admin.id, periodStart: last?.periodEnd ?? monthStart(now), periodEnd: now } });
}),
// ───── заведения ─────
venues: p.query(async ({ ctx }) => {
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 } })),
});
-398
View File
@@ -1,398 +0,0 @@
import { DAY, membershipLevel } from '@barev/core';
import type { Db } from '@barev/db';
import {
acceptSuggestion,
accruePoints,
confirmRedemption,
getSettings,
pointsHistory,
previewAccrual,
redeemPoints,
rejectRedemption,
rejectSuggestion,
reverseAccrual,
reversePreview,
reverseRedemption,
type PointsActor,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/**
* Барики в новой админке. Управляет суперадмин; поддержка — только если включено в настройках,
* с лимитами на операцию и на сутки (выше — подтверждение суперадмина).
*/
const pointsProcedure = rolesProcedure('SUPPORT').use(async ({ ctx, next }) => {
if (!ctx.isSuper && !(await getSettings(ctx.db))['points.supportCanManage']) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return next({ ctx: { actor: { adminId: ctx.admin.id, owner: ctx.isSuper } satisfies PointsActor } });
});
const name = (u: { firstName: string | null; lastName: string | null; username: string | null }) => [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
const L5 = z.object({ ru: z.string().max(100), en: z.string().max(100), zh: z.string().max(100), hy: z.string().max(100), es: z.string().max(100) });
const idem = z.string().min(8).max(80).optional();
/** Клиент по телефону, @username, имени или ID */
async function findClients(db: Db, q: string) {
const s = q.trim().replace(/^@/, '');
const digits = s.replace(/[^\d]/g, '');
return db.client.findMany({
where: {
OR: [
{ id: s },
{ cardCode: s.toUpperCase() },
{ user: { username: { equals: s, mode: 'insensitive' } } },
{ user: { firstName: { contains: s, mode: 'insensitive' } } },
{ user: { lastName: { contains: s, mode: 'insensitive' } } },
...(digits.length >= 5 ? [{ user: { phone: { contains: digits } } }] : []),
],
},
include: { user: true, subscriptions: { where: { status: 'ACTIVE' } } },
take: 20,
orderBy: { createdAt: 'desc' },
});
}
export const boPointsRouter = router({
/** Справочники формы: категории, типы бонусов, правила */
dictionaries: pointsProcedure.query(async ({ ctx }) => {
const [categories, bonusTypes, s] = await Promise.all([
ctx.db.pointsCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
ctx.db.pointsBonusType.findMany({ orderBy: { sortOrder: 'asc' } }),
getSettings(ctx.db),
]);
return {
categories: categories.map((c) => ({ ...c, name: c.name as Record<string, string> })),
bonusTypes: bonusTypes.map((b) => ({ ...b, name: b.name as Record<string, string> })),
rules: { pendingHours: s['points.pendingHours'], sharePct: s['points.maxSharePct'], min: s['points.minRedeem'], burnDays: s['points.burnDays'], diffPct: s['points.diffHighlightPct'] },
owner: ctx.isSuper,
};
}),
search: pointsProcedure.input(z.object({ q: z.string().trim().min(1).max(100) })).query(async ({ ctx, input }) => {
const now = ctx.svc.now();
return (await findClients(ctx.db, input.q)).map((c) => ({
id: c.id,
name: name(c.user),
username: c.user.username,
phone: c.user.phone,
tier: membershipLevel(c.subscriptions, now),
balance: c.pointsActive,
pending: c.pointsPending,
}));
}),
/** Карточка клиента: баланс, ожидание, сгорание, источник, флаг проверки, последние операции */
client: pointsProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const s = await getSettings(ctx.db);
const now = ctx.svc.now();
const c = await ctx.db.client.findUnique({ where: { id: input.id }, include: { user: true, agency: { select: { name: true } }, subscriptions: { where: { status: 'ACTIVE' } } } });
if (!c) throw new TRPCError({ code: 'NOT_FOUND' });
const [history, reversed30, pendingNext] = await Promise.all([
pointsHistory(ctx.db, c.id, { take: 10 }),
ctx.db.pointsReversal.count({ where: { clientId: c.id, targetType: 'ACCRUAL', createdAt: { gte: new Date(now.getTime() - 30 * DAY) } } }),
ctx.db.pointsAccrual.findFirst({ where: { clientId: c.id, status: 'PENDING' }, orderBy: { availableAt: 'asc' } }),
]);
return {
id: c.id,
name: name(c.user),
phone: c.user.phone,
username: c.user.username,
tier: membershipLevel(c.subscriptions, now),
agency: c.agency?.name ?? null,
balance: c.pointsActive,
pending: c.pointsPending,
pendingNextAt: pendingNext?.availableAt ?? null,
debt: c.pointsDebt,
burnAt: c.pointsActive > 0 && c.pointsActivityAt ? new Date(c.pointsActivityAt.getTime() + s['points.burnDays'] * DAY) : null,
streak: c.streakCount,
flagged: reversed30 > 2,
history,
};
}),
preview: pointsProcedure
.input(z.object({ clientId: z.string(), categoryId: z.string(), orderAmount: z.number().int().min(0), excludedAmount: z.number().int().min(0).default(0), paidWithPoints: z.number().int().min(0).default(0), serviceDate: z.coerce.date() }))
.query(async ({ ctx, input }) => previewAccrual(ctx.db, input, await getSettings(ctx.db), ctx.svc.now())),
/** Начисление по заказу или бонус */
accrue: pointsProcedure
.input(
z.object({
clientId: z.string(),
kind: z.enum(['ORDER', 'BONUS']),
categoryId: z.string().optional(),
bonusTypeId: z.string().optional(),
orderRef: z.string().trim().max(60).nullable().optional(),
orderAmount: z.number().int().min(0).optional(),
excludedAmount: z.number().int().min(0).optional(),
paidWithPoints: z.number().int().min(0).optional(),
serviceDate: z.coerce.date(),
amount: z.number().int().min(1).max(10_000_000).optional(),
reason: z.string().trim().max(500).nullable().optional(),
commentForClient: z.string().trim().max(300).nullable().optional(),
noWait: z.boolean().optional(),
ttlDays: z.number().int().min(1).max(1000).nullable().optional(),
idempotencyKey: idem,
}),
)
.mutation(({ ctx, input }) => accruePoints(ctx.svc, { ...input, channel: 'WEB' }, ctx.actor)),
/** Списание командой: по заказу вне системы или по звонку (с причиной) */
redeem: pointsProcedure
.input(z.object({ clientId: z.string(), amount: z.number().int().min(1), orderRef: z.string().trim().max(60).nullable().optional(), orderAmount: z.number().int().min(1).nullable().optional(), reason: z.string().trim().min(1).max(500), idempotencyKey: idem }))
.mutation(({ ctx, input }) => redeemPoints(ctx.svc, { ...input, channel: 'WEB' }, ctx.actor)),
/** Запросы клиентов «Оплатить бариками» */
requests: pointsProcedure.query(async ({ ctx }) => {
const list = await ctx.db.pointsRedemption.findMany({ where: { status: 'REQUESTED' }, orderBy: { requestedAt: 'asc' }, include: { client: { include: { user: true } }, order: { select: { id: true, number: true, totalPrice: true, pointsUsed: true } } } });
return list.map((r) => ({ id: r.id, clientId: r.clientId, name: name(r.client.user), balance: r.client.pointsActive, amount: r.amount, note: r.note, order: r.order, requestedAt: r.requestedAt, expiresAt: r.expiresAt }));
}),
confirm: pointsProcedure
.input(z.object({ id: z.string(), orderAmount: z.number().int().min(1), amount: z.number().int().min(1).optional() }))
.mutation(({ ctx, input }) => confirmRedemption(ctx.svc, { redemptionId: input.id, orderAmount: input.orderAmount, amount: input.amount }, ctx.actor)),
reject: pointsProcedure.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500) })).mutation(({ ctx, input }) => rejectRedemption(ctx.svc, { redemptionId: input.id, reason: input.reason }, ctx.actor)),
reversePreview: pointsProcedure.input(z.object({ type: z.enum(['ACCRUAL', 'REDEMPTION']), id: z.string() })).query(({ ctx, input }) => reversePreview(ctx.db, input)),
reverse: pointsProcedure
.input(z.object({ type: z.enum(['ACCRUAL', 'REDEMPTION']), id: z.string(), reason: z.string().trim().min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
if (input.type === 'ACCRUAL') await reverseAccrual(ctx.svc, { accrualId: input.id, reason: input.reason, channel: 'WEB' }, ctx.actor);
else await reverseRedemption(ctx.svc, { redemptionId: input.id, reason: input.reason, channel: 'WEB' }, ctx.actor);
}),
/** Журнал: все операции с автором и каналом; фильтры; выгрузка — на клиенте */
ledger: pointsProcedure
.input(
z.object({
clientId: z.string().optional(),
adminId: z.string().optional(),
type: z.enum(['accrual', 'bonus', 'redemption', 'reversal']).optional(),
categoryId: z.string().optional(),
channel: z.enum(['WEB', 'BOT', 'CSV', 'AUTO', 'CLIENT']).optional(),
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
take: z.number().int().min(1).max(2000).default(200),
}),
)
.query(async ({ ctx, input }) => {
const s = await getSettings(ctx.db);
const at = input.from || input.to ? { gte: input.from, lt: input.to } : undefined;
const want = (t: string) => !input.type || input.type === t;
const [acc, red, rev, admins] = await Promise.all([
want('accrual') || want('bonus')
? ctx.db.pointsAccrual.findMany({
where: { clientId: input.clientId, createdById: input.adminId, categoryId: input.categoryId, channel: input.channel, createdAt: at, ...(input.type === 'accrual' ? { kind: 'ORDER' } : input.type === 'bonus' ? { kind: 'BONUS' } : {}) },
include: { client: { include: { user: true } }, category: true, bonusType: true },
orderBy: { createdAt: 'desc' },
take: input.take,
})
: [],
want('redemption') && !input.categoryId
? ctx.db.pointsRedemption.findMany({ where: { clientId: input.clientId, confirmedById: input.adminId, channel: input.channel, status: { in: ['CONFIRMED', 'REVERSED', 'REJECTED'] }, requestedAt: at }, include: { client: { include: { user: true } }, order: { select: { number: true } } }, orderBy: { requestedAt: 'desc' }, take: input.take })
: [],
want('reversal') && !input.categoryId ? ctx.db.pointsReversal.findMany({ where: { clientId: input.clientId, createdById: input.adminId, channel: input.channel, createdAt: at }, orderBy: { createdAt: 'desc' }, take: input.take }) : [],
ctx.db.adminUser.findMany({ select: { id: true, name: true } }),
]);
const adminName = new Map(admins.map((a) => [a.id, a.name]));
const revClients = rev.length ? new Map((await ctx.db.client.findMany({ where: { id: { in: [...new Set(rev.map((r) => r.clientId))] } }, include: { user: true } })).map((c) => [c.id, name(c.user)])) : new Map<string, string>();
const rows = [
...acc.map((a) => {
const diff = a.suggestedAmount ? Math.abs(a.amount - a.suggestedAmount) / Math.max(1, a.suggestedAmount) : 0;
return {
id: a.id,
at: a.createdAt,
type: a.kind === 'BONUS' ? ('bonus' as const) : ('accrual' as const),
clientId: a.clientId,
client: name(a.client.user),
amount: a.amount,
suggested: a.suggestedAmount,
highlight: a.kind === 'ORDER' && diff * 100 > s['points.diffHighlightPct'],
status: a.status as string,
what: a.kind === 'BONUS' ? ((a.bonusType?.name as Record<string, string>) ?? null) : ((a.category?.name as Record<string, string>) ?? null),
orderRef: a.orderRef,
orderAmount: a.orderAmount,
tier: a.tier,
ratePct: a.ratePct,
author: a.createdById ? (adminName.get(a.createdById) ?? '—') : null,
channel: a.channel as string,
reason: a.reason,
comment: a.commentForClient,
canReverse: a.status === 'PENDING' || a.status === 'ACTIVE',
};
}),
...red.map((r) => ({
id: r.id,
at: r.confirmedAt ?? r.requestedAt,
type: 'redemption' as const,
clientId: r.clientId,
client: name(r.client.user),
amount: -r.amount,
suggested: null,
highlight: false,
status: r.status as string,
what: null,
orderRef: r.order ? `#${r.order.number}` : r.orderRef,
orderAmount: r.orderAmount,
tier: null,
ratePct: null,
author: r.confirmedById ? (adminName.get(r.confirmedById) ?? '—') : null,
channel: r.channel as string,
reason: r.reason,
comment: r.note,
canReverse: r.status === 'CONFIRMED',
})),
...rev.map((r) => ({
id: r.id,
at: r.createdAt,
type: 'reversal' as const,
clientId: r.clientId,
client: revClients.get(r.clientId) ?? '—',
amount: r.targetType === 'ACCRUAL' ? -r.amount : r.amount,
suggested: null,
highlight: r.debt > 0,
status: r.targetType,
what: null,
orderRef: null,
orderAmount: null,
tier: null,
ratePct: null,
author: r.createdById ? (adminName.get(r.createdById) ?? '—') : null,
channel: r.channel as string,
reason: r.reason,
comment: r.debt > 0 ? `debt:${r.debt}` : null,
canReverse: false,
})),
];
return rows.sort((a, b) => b.at.getTime() - a.at.getTime()).slice(0, input.take);
}),
/** CSV: телефон, категория, сумма заказа, дата, номер заказа. dryRun — предпросмотр с ошибками */
import: pointsProcedure
.input(z.object({ dryRun: z.boolean(), rows: z.array(z.object({ phone: z.string().max(40), category: z.string().max(40), orderAmount: z.number().int().min(0), date: z.string().max(20), orderRef: z.string().max(60) })).max(1000) }))
.mutation(async ({ ctx, input }) => {
const s = await getSettings(ctx.db);
const now = ctx.svc.now();
const cats = await ctx.db.pointsCategory.findMany();
const seen = new Set<string>();
const out: { line: number; clientId?: string; client?: string; amount?: number; error?: string }[] = [];
for (const [i, r] of input.rows.entries()) {
const line = i + 1;
const digits = r.phone.replace(/[^\d]/g, '');
const cat = cats.find((c) => c.code === r.category.trim().toLowerCase() || Object.values(c.name as Record<string, string>).some((n) => n.toLowerCase() === r.category.trim().toLowerCase()));
const date = new Date(`${r.date.trim()}T12:00:00+04:00`);
const ref = r.orderRef.trim();
if (!cat) {
out.push({ line, error: 'category' });
continue;
}
if (Number.isNaN(date.getTime())) {
out.push({ line, error: 'date' });
continue;
}
const users = digits.length >= 8 ? await ctx.db.user.findMany({ where: { phone: { endsWith: digits.slice(-8) } }, include: { client: true } }) : [];
const cl = users.find((u) => u.client)?.client;
if (!cl) {
out.push({ line, error: 'client' });
continue;
}
if (!ref || seen.has(ref) || (await ctx.db.pointsAccrual.findFirst({ where: { orderRef: ref, status: { not: 'REVERSED' } } }))) {
out.push({ line, error: 'duplicate' });
continue;
}
seen.add(ref);
const p = await previewAccrual(ctx.db, { clientId: cl.id, categoryId: cat.id, orderAmount: r.orderAmount, serviceDate: date }, s, now);
const u = users.find((x) => x.client)!;
if (!input.dryRun) {
await accruePoints(ctx.svc, { clientId: cl.id, kind: 'ORDER', categoryId: cat.id, orderAmount: r.orderAmount, orderRef: ref, serviceDate: date, channel: 'CSV', idempotencyKey: `csv:${ref}` }, ctx.actor);
}
out.push({ line, clientId: cl.id, client: name(u), amount: p.suggested });
}
return { rows: out, total: out.reduce((sum, r) => sum + (r.amount ?? 0), 0), errors: out.filter((r) => r.error).length };
}),
/** Очередь «Предложено системой» */
suggestions: pointsProcedure.query(async ({ ctx }) => {
const list = await ctx.db.pointsSuggestion.findMany({ where: { status: 'OPEN' }, orderBy: { createdAt: 'asc' }, include: { client: { include: { user: true } } }, take: 200 });
const admins = new Map((await ctx.db.adminUser.findMany({ select: { id: true, name: true } })).map((a) => [a.id, a.name]));
return list.map((x) => {
const p = x.payload as Record<string, unknown>;
return { id: x.id, clientId: x.clientId, name: name(x.client.user), mechanic: x.mechanic, amount: x.suggestedAmount, payload: p, by: typeof p.adminId === 'string' ? (admins.get(p.adminId) ?? null) : null, createdAt: x.createdAt };
});
}),
acceptSuggestion: superProcedure
.input(z.object({ id: z.string(), amount: z.number().int().min(1).optional(), reason: z.string().trim().max(500).nullable().optional() }))
.mutation(({ ctx, input }) => acceptSuggestion(ctx.svc, input, { adminId: ctx.admin.id, owner: true })),
rejectSuggestion: superProcedure.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500) })).mutation(({ ctx, input }) => rejectSuggestion(ctx.svc, input, { adminId: ctx.admin.id, owner: true })),
/** Справочники — только суперадмин */
saveCategory: superProcedure
.input(z.object({ id: z.string().optional(), code: z.string().trim().regex(/^[a-z_]{2,30}$/), name: L5, rateBarev: z.number().int().min(0).max(100), ratePlus: z.number().int().min(0).max(100), rateElite: z.number().int().min(0).max(100), active: z.boolean(), sortOrder: z.number().int().default(0) }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return id ? ctx.db.pointsCategory.update({ where: { id }, data }) : ctx.db.pointsCategory.create({ data });
}),
saveBonusType: superProcedure
.input(z.object({ id: z.string().optional(), code: z.string().trim().regex(/^[a-z_]{2,30}$/), name: L5, defaultAmount: z.number().int().min(0), ttlDays: z.number().int().min(1).max(1000).nullable(), active: z.boolean(), sortOrder: z.number().int().default(0) }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return id ? ctx.db.pointsBonusType.update({ where: { id }, data }) : ctx.db.pointsBonusType.create({ data });
}),
/** Отчёт за период: начислено, потрачено, сгорело, отменено, обязательства, redemption rate, повторные заказы, топы */
report: superProcedure
.input(z.object({ from: z.coerce.date(), to: z.coerce.date(), categoryId: z.string().optional(), agencyId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const at = { gte: input.from, lt: input.to };
const clientWhere = input.agencyId ? { agencyId: input.agencyId } : {};
const accWhere = { createdAt: at, categoryId: input.categoryId, client: clientWhere };
const [accrued, spent, expired, reversed, liabilities, byCategory, topClients, topAgencies, payers] = await Promise.all([
ctx.db.pointsAccrual.aggregate({ where: accWhere, _sum: { amount: true } }),
ctx.db.pointsRedemption.aggregate({ where: { status: 'CONFIRMED', confirmedAt: at, client: clientWhere }, _sum: { amount: true } }),
// сгоревшее — по начислениям этого периода (дату сгорания журнал хранит в статусе)
ctx.db.pointsAccrual.aggregate({ where: { ...accWhere, expiredAmount: { gt: 0 } }, _sum: { expiredAmount: true } }),
ctx.db.pointsReversal.aggregate({ where: { targetType: 'ACCRUAL', createdAt: at }, _sum: { amount: true } }),
ctx.db.pointsAccrual.aggregate({ where: { status: { in: ['ACTIVE', 'PENDING'] }, categoryId: input.categoryId, client: clientWhere }, _sum: { remaining: true } }),
ctx.db.$queryRaw<{ code: string; name: Record<string, string>; points: number; revenue: number }[]>`
SELECT pc.code, pc.name, COALESCE(SUM(a.amount),0)::int AS points, COALESCE(SUM(COALESCE(a."orderAmount",0) - a."excludedAmount" - a."paidWithPoints"),0)::int AS revenue
FROM "PointsAccrual" a JOIN "PointsCategory" pc ON pc.id = a."categoryId"
WHERE a.kind = 'ORDER' AND a.status <> 'REVERSED' AND a."createdAt" >= ${input.from} AND a."createdAt" < ${input.to}
GROUP BY pc.code, pc.name`,
ctx.db.client.findMany({ where: { pointsActive: { gt: 0 }, ...clientWhere }, orderBy: { pointsActive: 'desc' }, take: 10, include: { user: true } }),
ctx.db.$queryRaw<{ name: string; n: number }[]>`
SELECT ag.name, COUNT(*)::int AS n FROM "PointsAccrual" a
JOIN "PointsBonusType" bt ON bt.id = a."bonusTypeId" AND bt.code = 'housewarming'
JOIN "Client" c ON c.id = a."clientId" JOIN "Agency" ag ON ag.id = c."agencyId"
WHERE a."createdAt" >= ${input.from} AND a."createdAt" < ${input.to}
GROUP BY ag.name ORDER BY n DESC LIMIT 10`,
// повторный заказ за 30 дней: у клиентов с бариками и без
ctx.db.$queryRaw<{ with_points: boolean; clients: number; repeat: number; multi: number }[]>`
WITH firsts AS (
SELECT o."clientId", MIN(o."createdAt") AS first_at, COUNT(DISTINCT s."pointsCategoryId") AS cats
FROM "Order" o JOIN "Service" s ON s.id = o."serviceId"
WHERE o.status <> 'CANCELLED' AND o."createdAt" >= ${input.from} AND o."createdAt" < ${input.to}
GROUP BY o."clientId"
)
SELECT (c."pointsActive" + c."pointsPending" > 0 OR EXISTS (SELECT 1 FROM "PointsAccrual" a WHERE a."clientId" = f."clientId")) AS with_points,
COUNT(*)::int AS clients,
COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Order" o2 WHERE o2."clientId" = f."clientId" AND o2.status <> 'CANCELLED' AND o2."createdAt" > f.first_at AND o2."createdAt" <= f.first_at + interval '30 days'))::int AS repeat,
COUNT(*) FILTER (WHERE f.cats >= 2)::int AS multi
FROM firsts f JOIN "Client" c ON c.id = f."clientId"
GROUP BY 1`,
]);
const a = accrued._sum.amount ?? 0;
const sp = spent._sum.amount ?? 0;
return {
accrued: a,
spent: sp,
expired: expired._sum.expiredAmount ?? 0,
reversed: reversed._sum.amount ?? 0,
liabilities: liabilities._sum.remaining ?? 0,
redemptionRate: a ? Math.round((sp / a) * 1000) / 10 : 0,
byCategory: byCategory.map((c) => ({ ...c, costPct: c.revenue ? Math.round((c.points / c.revenue) * 1000) / 10 : 0 })),
topClients: topClients.map((c) => ({ id: c.id, name: name(c.user), balance: c.pointsActive })),
topAgencies,
retention: payers.map((p) => ({ withPoints: p.with_points, clients: p.clients, repeatPct: p.clients ? Math.round((p.repeat / p.clients) * 1000) / 10 : 0, multi: p.multi })),
};
}),
});
-307
View File
@@ -1,307 +0,0 @@
import { randomBytes } from 'node:crypto';
import { CATEGORY_CODES, DAY, fromPrice, paramsSchemaSchema, pricingSchema, ROLE_FLOWS, slotConfigSchema } from '@barev/core';
import { createT, defaultText, LOCALES, type Locale } from '@barev/i18n';
import { isEditableTextKey, listRelocationItems, loadTextOverrides, moveRelocationItem, normalizePromo, saveRelocationItem } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/** Продукты видят поддержка и суперадмин; меняет — суперадмин (цены и скидки — деньги) */
const view = rolesProcedure('SUPPORT');
const edit = superProcedure;
const localized = z.object({ ru: z.string().max(500), en: z.string().max(500), zh: z.string().max(500), hy: z.string().max(500), es: z.string().max(500) });
/**
* С какими исполнителями можно завести новую услугу с нуля: у них простой сценарий заказа.
* Ремонт (смета) и уроки (пакет занятий) — только копией существующей услуги.
*/
const NEW_SERVICE_ROLES = ['CLEANER', 'DRIVER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
const DEFAULT_SLOTS = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 };
/** Ключи подписей вопросов услуги: params.<код>.<поле>.label и варианты */
function paramKeys(schema: { fields: { key: string; type: string; options?: string[] }[] }, code: string) {
return schema.fields.flatMap((f) => [`params.${code}.${f.key}.label`, ...(f.options ?? []).flatMap((o) => [`params.${code}.${f.key}.${o}`, `params.${code}.${f.key}.${o}_hint`])]);
}
/** Текст по ключу на 5 языках: стандартный (из переводов) и изменённый в админке */
async function textsFor(db: import('@barev/db').Db, keys: string[]) {
const rows = await db.textOverride.findMany({ where: { key: { in: keys } } });
const out: Record<string, Record<Locale, { default: string | null; value: string | null }>> = {};
for (const key of keys) {
out[key] = Object.fromEntries(LOCALES.map((l) => [l, { default: defaultText(l, key), value: rows.find((r) => r.key === key && r.locale === l)?.value ?? null }])) as Record<Locale, { default: string | null; value: string | null }>;
}
return out;
}
export const boProductsRouter = router({
/** Направления (скидки по уровням) и услуги со всеми настройками */
catalog: view.query(async ({ ctx }) => {
const since = new Date(ctx.svc.now().getTime() - 30 * DAY);
const [categories, orders30] = await Promise.all([
ctx.db.category.findMany({ orderBy: { sortOrder: 'asc' }, include: { services: { orderBy: { sortOrder: 'asc' }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } } } }),
ctx.db.order.groupBy({ by: ['serviceId'], where: { createdAt: { gte: since }, status: { not: 'CANCELLED' } }, _count: true }),
]);
const count = new Map(orders30.map((o) => [o.serviceId, o._count]));
return categories.map((c) => ({
id: c.id,
code: c.code,
barevPlusDiscountPct: c.barevPlusDiscountPct,
eliteDiscountPct: c.eliteDiscountPct,
services: c.services.map((s) => {
const pricing = pricingSchema.parse(s.pricing);
return {
id: s.id,
code: s.code,
flow: s.flow,
providerRole: s.providerRole,
active: s.active,
isOnline: s.isOnline,
lateCancelFeePct: s.lateCancelFeePct,
pricing,
paramsSchema: paramsSchemaSchema.parse(s.paramsSchema),
slotConfig: slotConfigSchema.parse(s.slotConfig),
fromPrice: fromPrice(pricing),
orders30: count.get(s.id) ?? 0,
checklist: s.checklist.map((x) => ({ code: x.code, requiresPhoto: x.requiresPhoto })),
};
}),
}));
}),
/** Тексты для формы: название/описание услуги, доп. услуги, пункты чек-листа */
texts: view.input(z.object({ keys: z.array(z.string().max(120)).max(200) })).query(({ ctx, input }) => textsFor(ctx.db, input.keys.filter(isEditableTextKey))),
/** Сохранить тексты; пустое значение — вернуть стандартный перевод */
saveTexts: edit.input(z.array(z.object({ key: z.string().max(120), locale: z.enum(LOCALES), value: z.string().max(2000) })).max(400)).mutation(async ({ ctx, input }) => {
for (const r of input) {
if (!isEditableTextKey(r.key)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'TEXT_KEY_FORBIDDEN' });
const value = r.value.trim();
const same = value === (defaultText(r.locale, r.key) ?? '');
if (!value || same) await ctx.db.textOverride.deleteMany({ where: { key: r.key, locale: r.locale } });
else await ctx.db.textOverride.upsert({ where: { key_locale: { key: r.key, locale: r.locale } }, create: { key: r.key, locale: r.locale, value, updatedById: ctx.admin.id }, update: { value, updatedById: ctx.admin.id } });
}
// этот процесс видит правки сразу; боты и воркер — в течение минуты
await loadTextOverrides(ctx.db);
}),
saveService: edit
.input(z.object({ id: z.string(), active: z.boolean(), lateCancelFeePct: z.number().int().min(0).max(100), pricing: pricingSchema, slotConfig: slotConfigSchema }))
.mutation(async ({ ctx, input }) => {
if (input.slotConfig.dayEndMinute <= input.slotConfig.dayStartMinute) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SLOT_WINDOW_INVALID' });
const { id, ...data } = input;
await ctx.db.service.update({ where: { id }, data });
}),
saveCategory: edit
.input(z.object({ id: z.string(), barevPlusDiscountPct: z.number().int().min(0).max(100), eliteDiscountPct: z.number().int().min(0).max(100) }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
await ctx.db.category.update({ where: { id }, data });
}),
/** Чек-лист услуги: новые заказы получают новый список, уже созданные — без изменений */
saveChecklist: edit
.input(z.object({ serviceId: z.string(), items: z.array(z.object({ code: z.string().max(60).optional(), label: z.string().max(120).optional(), requiresPhoto: z.boolean() })).max(30) }))
.mutation(async ({ ctx, input }) => {
const items = input.items.map((x) => ({ ...x, code: x.code || `c_${randomBytes(3).toString('hex')}` }));
await ctx.db.$transaction([
ctx.db.checklistItem.deleteMany({ where: { serviceId: input.serviceId } }),
ctx.db.checklistItem.createMany({ data: items.map((x, i) => ({ serviceId: input.serviceId, code: x.code, requiresPhoto: x.requiresPhoto, sortOrder: i })) }),
]);
// подпись нового пункта — русский текст (переводы можно добавить в «Текстах»)
for (const x of items) {
if (x.label?.trim() && !defaultText('ru', `checklist.${x.code}`)) {
await ctx.db.textOverride.upsert({ where: { key_locale: { key: `checklist.${x.code}`, locale: 'ru' } }, create: { key: `checklist.${x.code}`, locale: 'ru', value: x.label.trim(), updatedById: ctx.admin.id }, update: { value: x.label.trim() } });
}
}
await loadTextOverrides(ctx.db);
}),
// ───── новые услуги ─────
/**
* Новая услуга: с нуля (фикс-цена, свои вопросы клиенту) или копия существующей (с её ценами, вопросами и чек-листом).
* Код генерируется; название и описание — тексты из админки. Создаётся выключенной: включите, когда всё настроено.
*/
createService: edit
.input(
z.object({
categoryId: z.string(),
name: localized.extend({ ru: z.string().trim().min(1).max(120) }),
description: localized,
copyFromId: z.string().optional(),
providerRole: z.enum(NEW_SERVICE_ROLES).optional(),
isOnline: z.boolean().default(false),
price: z.number().int().min(0).default(0),
minutes: z.number().int().min(15).max(24 * 60).default(60),
}),
)
.mutation(async ({ ctx, input }) => {
const code = `s${randomBytes(4).toString('hex')}`;
const src = input.copyFromId ? await ctx.db.service.findUnique({ where: { id: input.copyFromId }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } }) : null;
if (input.copyFromId && !src) throw new TRPCError({ code: 'NOT_FOUND' });
if (!src && !input.providerRole) throw new TRPCError({ code: 'BAD_REQUEST', message: 'ROLE_REQUIRED' });
const role = src?.providerRole ?? input.providerRole!;
const last = await ctx.db.service.findFirst({ where: { categoryId: input.categoryId }, orderBy: { sortOrder: 'desc' } });
const created = await ctx.db.service.create({
data: {
code,
categoryId: input.categoryId,
flow: src?.flow ?? ROLE_FLOWS[role][0]!,
providerRole: role,
isOnline: src?.isOnline ?? input.isOnline,
pricing: src ? (src.pricing as object) : { kind: 'fixed', price: input.price, minutes: input.minutes },
paramsSchema: src ? (src.paramsSchema as object) : { fields: [] },
slotConfig: src ? (src.slotConfig as object) : DEFAULT_SLOTS,
lateCancelFeePct: src?.lateCancelFeePct ?? 30,
active: false,
sortOrder: (last?.sortOrder ?? 0) + 1,
checklist: src ? { create: src.checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })) } : undefined,
},
});
// тексты: название и описание; у копии — ещё подписи вопросов (params.<код>.*) на всех языках
const rows: { key: string; locale: Locale; value: string }[] = [];
for (const l of LOCALES) {
if (input.name[l]?.trim()) rows.push({ key: `services.${code}.name`, locale: l, value: input.name[l].trim() });
if (input.description[l]?.trim()) rows.push({ key: `services.${code}.description`, locale: l, value: input.description[l].trim() });
}
if (src) {
for (const l of LOCALES) {
const t = createT(l);
for (const key of paramKeys(paramsSchemaSchema.parse(src.paramsSchema), src.code)) {
const v = t(key);
if (v !== key) rows.push({ key: key.replace(`params.${src.code}.`, `params.${code}.`), locale: l, value: v });
}
}
}
for (const r of rows) await ctx.db.textOverride.create({ data: { ...r, updatedById: ctx.admin.id } });
await loadTextOverrides(ctx.db);
return { id: created.id };
}),
/**
* Вопросы клиенту при заказе (для услуг с фикс-ценой): выбор из вариантов, число, текст, да/нет.
* Подписи — по-русски (в других языках клиент увидит русский, пока нет перевода).
*/
saveParams: edit
.input(
z.object({
serviceId: z.string(),
fields: z
.array(
z.object({
key: z.string().max(40).optional(),
type: z.enum(['select', 'int', 'text', 'bool']),
label: z.string().trim().min(1).max(120),
required: z.boolean(),
options: z.array(z.object({ key: z.string().max(40).optional(), label: z.string().trim().min(1).max(120) })).max(20).default([]),
min: z.number().int().min(0).max(1000).default(1),
max: z.number().int().min(1).max(1000).default(10),
}),
)
.max(12),
}),
)
.mutation(async ({ ctx, input }) => {
const s = await ctx.db.service.findUnique({ where: { id: input.serviceId } });
if (!s) throw new TRPCError({ code: 'NOT_FOUND' });
if (pricingSchema.parse(s.pricing).kind !== 'fixed') throw new TRPCError({ code: 'BAD_REQUEST', message: 'PARAMS_LOCKED' });
const short = () => randomBytes(2).toString('hex');
const fields = input.fields.map((f) => ({ ...f, key: f.key || `q${short()}`, options: f.options.map((o) => ({ ...o, key: o.key || `o${short()}` })) }));
for (const f of fields) if (f.type === 'select' && f.options.length < 2) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OPTIONS_REQUIRED' });
const schema = {
fields: fields.map((f) =>
f.type === 'select'
? { key: f.key, type: 'select' as const, options: f.options.map((o) => o.key), required: f.required }
: f.type === 'int'
? { key: f.key, type: 'int' as const, min: Math.min(f.min, f.max), max: Math.max(f.min, f.max), default: Math.min(f.min, f.max), required: f.required }
: f.type === 'text'
? { key: f.key, type: 'text' as const, maxLength: 1000, required: f.required }
: { key: f.key, type: 'bool' as const, default: false, required: false },
),
};
paramsSchemaSchema.parse(schema);
await ctx.db.service.update({ where: { id: s.id }, data: { paramsSchema: schema } });
// подписи: старые подписи вопросов этой услуги убираем, новые пишем
await ctx.db.textOverride.deleteMany({ where: { key: { startsWith: `params.${s.code}.` }, locale: 'ru' } });
for (const f of fields) {
await ctx.db.textOverride.create({ data: { key: `params.${s.code}.${f.key}.label`, locale: 'ru', value: f.label, updatedById: ctx.admin.id } });
for (const o of f.options) await ctx.db.textOverride.create({ data: { key: `params.${s.code}.${f.key}.${o.key}`, locale: 'ru', value: o.label, updatedById: ctx.admin.id } });
}
await loadTextOverrides(ctx.db);
}),
/** Удалить можно только услугу без заказов; иначе — выключить */
deleteService: edit.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const s = await ctx.db.service.findUnique({ where: { id: input.id }, include: { _count: { select: { orders: true, webRequests: true } } } });
if (!s) throw new TRPCError({ code: 'NOT_FOUND' });
if (s._count.orders || s._count.webRequests) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SERVICE_HAS_ORDERS' });
await ctx.db.service.delete({ where: { id: s.id } });
await ctx.db.textOverride.deleteMany({ where: { OR: [{ key: { startsWith: `services.${s.code}.` } }, { key: { startsWith: `params.${s.code}.` } }] } });
await loadTextOverrides(ctx.db);
}),
/** Порядок услуг в направлении — как в каталоге у клиента */
moveService: edit.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(async ({ ctx, input }) => {
const s = await ctx.db.service.findUniqueOrThrow({ where: { id: input.id } });
const list = await ctx.db.service.findMany({ where: { categoryId: s.categoryId }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
const i = list.findIndex((x) => x.id === s.id);
const j = i + input.dir;
if (j < 0 || j >= list.length) return;
[list[i], list[j]] = [list[j]!, list[i]!];
await ctx.db.$transaction(list.map((x, k) => ctx.db.service.update({ where: { id: x.id }, data: { sortOrder: k } })));
}),
// ───── промокоды ─────
promos: view.query(async ({ ctx }) => {
const list = await ctx.db.promoCode.findMany({ where: { kind: 'DISCOUNT' }, orderBy: [{ active: 'desc' }, { createdAt: 'desc' }] });
const now = ctx.svc.now();
return list.map((p) => ({
id: p.id,
code: p.code,
discountPct: p.discountPct ?? 0,
maxUses: p.maxUses,
usedCount: p.usedCount,
validFrom: p.validFrom,
validUntil: p.validUntil,
categories: p.categories,
note: p.note,
active: p.active,
state: !p.active ? 'off' : p.validUntil && p.validUntil < now ? 'expired' : p.maxUses != null && p.usedCount >= p.maxUses ? 'exhausted' : p.validFrom && p.validFrom > now ? 'scheduled' : 'live',
}));
}),
savePromo: edit
.input(
z.object({
id: z.string().optional(),
code: z.string().min(3).max(40),
discountPct: z.number().int().min(1).max(100),
maxUses: z.number().int().min(1).nullable(),
validFrom: z.date().nullable(),
validUntil: z.date().nullable(),
categories: z.array(z.enum(CATEGORY_CODES)),
note: z.string().max(300).nullable(),
active: z.boolean(),
}),
)
.mutation(async ({ ctx, input }) => {
const code = normalizePromo(input.code);
if (!/^[A-Z0-9_-]{3,40}$/.test(code)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'PROMO_CODE_FORMAT' });
const clash = await ctx.db.promoCode.findUnique({ where: { code } });
if (clash && clash.id !== input.id) throw new TRPCError({ code: 'BAD_REQUEST', message: 'PROMO_CODE_TAKEN' });
const { id, ...rest } = input;
const data = { ...rest, code, kind: 'DISCOUNT' as const };
if (id) await ctx.db.promoCode.update({ where: { id }, data });
else await ctx.db.promoCode.create({ data });
}),
// ───── релокация: общий список шагов и документов ─────
relocation: view.query(({ ctx }) => listRelocationItems(ctx.svc)),
saveRelocation: edit
.input(z.object({ id: z.string().optional(), kind: z.enum(['STEP', 'DOC']), name: localized, hint: localized.nullable().optional(), active: z.boolean(), inTurnkey: z.boolean().optional() }))
.mutation(({ ctx, input }) => saveRelocationItem(ctx.svc, input).then((x) => ({ id: x.id }))),
moveRelocation: edit.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(({ ctx, input }) => moveRelocationItem(ctx.svc, input)),
});
@@ -1,259 +0,0 @@
import { randomBytes } from 'node:crypto';
import { averageRating, DAY, LOCALES, PROVIDER_ROLES, startOfYerevanDay } from '@barev/core';
import type { Prisma } from '@barev/db';
import { getSettings, orderPayout, rateLabel } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { env } from '../../env.ts';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/** Исполнителей ведёт поддержка; ставки и деньги — только суперадмин */
const providersProcedure = rolesProcedure('SUPPORT');
const invite = () => `PRO-${randomBytes(5).toString('hex').toUpperCase()}`;
const inviteUrl = (code: string) => `https://t.me/${env.PRO_BOT_USERNAME}?start=${code}`;
const DONE = { completedAt: { not: null }, status: { not: 'CANCELLED' as const } } satisfies Prisma.OrderWhereInput;
const weekSchedule = z.array(z.object({ weekday: z.number().int().min(0).max(6), startMinute: z.number().int().min(0).max(1440), endMinute: z.number().int().min(0).max(1440) })).max(21);
const profileInput = z.object({
name: z.string().trim().min(1).max(100),
phone: z.string().trim().max(30).nullable().optional(),
role: z.enum(PROVIDER_ROLES),
companyId: z.string().nullable().optional(),
languages: z.array(z.enum(LOCALES)).min(1),
districtIds: z.array(z.string()).max(30),
schedule: weekSchedule,
adminNote: z.string().max(4000).nullable().optional(),
/** ставка — меняет только суперадмин */
rate: z.object({ rateType: z.enum(['FIXED', 'PERCENT']), rateValue: z.number().int().min(0), payoutPresetId: z.string().nullable() }).optional(),
});
const payoutInclude = { company: { select: { deal: true, fotPct: true } }, payoutPreset: { select: { name: true, rules: true } } } as const;
export const boProvidersRouter = router({
list: providersProcedure
.input(z.object({ role: z.enum(PROVIDER_ROLES).optional(), status: z.enum(['active', 'blocked', 'all']).default('active'), search: z.string().trim().max(100).optional() }))
.query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const from30 = new Date(now.getTime() - 30 * DAY);
const today = startOfYerevanDay(now);
const providers = await ctx.db.provider.findMany({
where: {
...(input.role ? { role: input.role } : {}),
...(input.status === 'active' ? { active: true } : input.status === 'blocked' ? { active: false } : {}),
...(input.search ? { OR: [{ name: { contains: input.search, mode: 'insensitive' } }, { phone: { contains: input.search } }] } : {}),
},
orderBy: [{ active: 'desc' }, { name: 'asc' }],
include: { company: { select: { id: true, name: true } }, districts: { include: { district: { select: { code: true } } } }, _count: { select: { docs: true } } },
});
const ids = providers.map((p) => p.id);
const [done30, refused30, low30, upcoming] = await Promise.all([
ctx.db.order.groupBy({ by: ['providerId'], where: { providerId: { in: ids }, ...DONE, completedAt: { gte: from30 } }, _count: true }),
ctx.db.orderOffer.groupBy({ by: ['providerId'], where: { providerId: { in: ids }, status: { in: ['DECLINED', 'EXPIRED'] }, createdAt: { gte: from30 } }, _count: true }),
ctx.db.rating.groupBy({ by: ['providerId'], where: { providerId: { in: ids }, score: { lte: 3 }, createdAt: { gte: from30 } }, _count: true }),
ctx.db.order.findMany({
where: { providerId: { in: ids }, scheduledAt: { gte: today }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED', 'LESSON_DONE'] } },
orderBy: { scheduledAt: 'asc' },
select: { providerId: true, scheduledAt: true, number: true, id: true },
}),
]);
const by = <T extends { providerId: string | null; _count: number }>(rows: T[]) => new Map(rows.map((r) => [r.providerId, r._count]));
const done = by(done30);
const refused = by(refused30);
const low = by(low30);
const tomorrow = new Date(today.getTime() + DAY);
return providers.map((p) => {
const mine = upcoming.filter((o) => o.providerId === p.id);
return {
id: p.id,
name: p.name,
phone: p.phone,
role: p.role,
company: p.company,
active: p.active,
blockedReason: p.blockedReason,
onShift: p.onShift,
linked: !!p.userId,
languages: p.languages,
districts: p.districts.map((d) => d.district.code),
rating: averageRating(p.ratingSum, p.ratingCount),
ratingCount: p.ratingCount,
done30: done.get(p.id) ?? 0,
refused30: refused.get(p.id) ?? 0,
low30: low.get(p.id) ?? 0,
today: mine.filter((o) => o.scheduledAt < tomorrow).length,
next: mine[0] ? { id: mine[0].id, number: mine[0].number, scheduledAt: mine[0].scheduledAt } : null,
docs: p._count.docs,
};
});
}),
get: providersProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const from30 = new Date(now.getTime() - 30 * DAY);
const p = await ctx.db.provider.findUnique({
where: { id: input.id },
include: {
...payoutInclude,
company: { select: { id: true, name: true, deal: true, fotPct: true } },
user: { select: { username: true, firstName: true } },
districts: true,
schedule: { orderBy: [{ weekday: 'asc' }, { startMinute: 'asc' }] },
docs: { orderBy: { createdAt: 'desc' } },
},
});
if (!p) throw new TRPCError({ code: 'NOT_FOUND' });
const [ratings, offers30, cancelled30, upcoming, recent] = await Promise.all([
ctx.db.rating.findMany({ where: { providerId: p.id }, orderBy: { createdAt: 'desc' }, take: 20, include: { order: { select: { id: true, number: true } }, client: { include: { user: { select: { firstName: true } } } } } }),
ctx.db.orderOffer.groupBy({ by: ['status'], where: { providerId: p.id, createdAt: { gte: from30 } }, _count: true }),
ctx.db.orderStatusHistory.count({ where: { event: 'unassigned', createdAt: { gte: from30 }, order: { offers: { some: { providerId: p.id, status: 'ACCEPTED' } } } } }),
ctx.db.order.findMany({
where: { providerId: p.id, scheduledAt: { gte: startOfYerevanDay(now) }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED', 'LESSON_DONE'] } },
orderBy: { scheduledAt: 'asc' },
take: 10,
include: { service: { select: { code: true, flow: true } }, district: { select: { code: true } } },
}),
ctx.db.order.findMany({
where: { providerId: p.id, ...DONE },
orderBy: { completedAt: 'desc' },
take: 15,
include: { service: { select: { code: true, pricing: true, paramsSchema: true } }, rating: { select: { score: true } } },
}),
]);
const offerCount = (s: string) => offers30.find((o) => o.status === s)?._count ?? 0;
// деньги: заработано (по ставке исполнителя) минус выплачено = к выплате
let money = null;
if (ctx.isSuper) {
const fot = { fotPct: (await getSettings(ctx.db))['partner.fotPct'] };
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
const [doneAll, payouts] = await Promise.all([
ctx.db.order.findMany({ where: { providerId: p.id, ...DONE }, select: { completedAt: true, totalPrice: true, basePrice: true, params: true, priceBreakdown: true, service: { select: { pricing: true, paramsSchema: true } } } }),
ctx.db.payout.findMany({ where: { providerId: p.id }, orderBy: { createdAt: 'desc' } }),
]);
const earn = doneAll.map((o) => ({ at: o.completedAt!, sum: orderPayout(o, p, fot) }));
const sum = (f: (x: (typeof earn)[number]) => boolean) => earn.filter(f).reduce((s, x) => s + x.sum, 0);
const earnedTotal = sum(() => true);
const paidTotal = payouts.filter((x) => x.status === 'PAID').reduce((s, x) => s + x.amount, 0);
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: payouts.map((x) => x.createdById).filter((x): x is string => !!x) } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
money = {
rate: rateLabel(p, fot),
thisMonth: sum((x) => x.at >= monthStart),
prevMonth: sum((x) => x.at >= prevMonthStart && x.at < monthStart),
earnedTotal,
paidTotal,
balance: earnedTotal - paidTotal,
payouts: payouts.map((x) => ({ id: x.id, amount: x.amount, status: x.status, paidAt: x.paidAt, note: x.note, by: x.createdById ? (admins.get(x.createdById) ?? null) : null, periodStart: x.periodStart, periodEnd: x.periodEnd })),
presets: (await ctx.db.payoutPreset.findMany({ where: { active: true, role: p.role }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true } })),
rateType: p.rateType,
rateValue: p.rateValue,
payoutPresetId: p.payoutPresetId,
};
}
return {
id: p.id,
name: p.name,
phone: p.phone,
role: p.role,
company: p.company ? { id: p.company.id, name: p.company.name } : null,
languages: p.languages,
districtIds: p.districts.map((d) => d.districtId),
schedule: p.schedule.map((s) => ({ weekday: s.weekday, startMinute: s.startMinute, endMinute: s.endMinute })),
active: p.active,
blockedReason: p.blockedReason,
blockedAt: p.blockedAt,
adminNote: p.adminNote,
onShift: p.onShift,
linked: !!p.userId,
username: p.user?.username ?? null,
inviteUrl: p.userId ? null : inviteUrl(p.inviteCode),
createdAt: p.createdAt,
docs: p.docs.map((d) => ({ id: d.id, title: d.title, fileName: d.fileName, mime: d.mime, size: d.size, createdAt: d.createdAt })),
quality: {
rating: averageRating(p.ratingSum, p.ratingCount),
ratingCount: p.ratingCount,
accepted30: offerCount('ACCEPTED'),
declined30: offerCount('DECLINED'),
expired30: offerCount('EXPIRED'),
unassigned30: cancelled30,
low30: ratings.filter((r) => r.score <= 3 && r.createdAt >= from30).length,
},
ratings: ratings.map((r) => ({ id: r.id, score: r.score, comment: r.comment, orderId: r.order.id, orderNumber: r.order.number, client: r.client.user.firstName, at: r.createdAt })),
upcoming: upcoming.map((o) => ({ id: o.id, number: o.number, service: o.service.code, flow: o.service.flow, scheduledAt: o.scheduledAt, status: o.status, district: o.district?.code ?? null })),
recent: recent.map((o) => ({ id: o.id, number: o.number, completedAt: o.completedAt, rating: o.rating?.score ?? null })),
money,
};
}),
/** Справочники для формы */
dictionaries: providersProcedure.query(async ({ ctx }) => ({
districts: (await ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } })).map((d) => ({ id: d.id, code: d.code })),
companies: await ctx.db.providerCompany.findMany({ where: { active: true }, orderBy: { name: 'asc' }, select: { id: true, name: true, roles: true } }),
})),
create: providersProcedure.input(profileInput).mutation(async ({ ctx, input }) => {
const { districtIds, schedule, rate, ...data } = input;
const p = await ctx.db.provider.create({
data: {
...data,
...(ctx.isSuper && rate ? rate : {}),
inviteCode: invite(),
districts: { create: districtIds.map((districtId) => ({ districtId })) },
schedule: { create: schedule.filter((s) => s.endMinute > s.startMinute) },
},
});
return { id: p.id, inviteUrl: inviteUrl(p.inviteCode) };
}),
update: providersProcedure.input(profileInput.extend({ id: z.string() })).mutation(async ({ ctx, input }) => {
const { id, districtIds, schedule, rate, ...data } = input;
await ctx.db.provider.update({
where: { id },
data: {
...data,
...(ctx.isSuper && rate ? rate : {}),
districts: { deleteMany: {}, create: districtIds.map((districtId) => ({ districtId })) },
schedule: { deleteMany: {}, create: schedule.filter((s) => s.endMinute > s.startMinute) },
},
});
}),
/** Допуск: заблокировать (с причиной) или вернуть к работе. Заблокированному заказы не предлагаются */
setActive: providersProcedure
.input(z.object({ id: z.string(), active: z.boolean(), reason: z.string().trim().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
if (!input.active && !input.reason) throw new TRPCError({ code: 'BAD_REQUEST', message: 'REASON_REQUIRED' });
await ctx.db.provider.update({
where: { id: input.id },
data: input.active ? { active: true, blockedReason: null, blockedAt: null } : { active: false, onShift: false, blockedReason: input.reason, blockedAt: ctx.svc.now() },
});
// заблокирован — действующие предложения ему отзываем
if (!input.active) await ctx.db.orderOffer.updateMany({ where: { providerId: input.id, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: ctx.svc.now() } });
}),
/** Новая ссылка для входа в рабочий бот — старая привязка снимается */
resetInvite: providersProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const p = await ctx.db.provider.update({ where: { id: input.id }, data: { inviteCode: invite(), userId: null, onShift: false } });
return { inviteUrl: inviteUrl(p.inviteCode) };
}),
removeDoc: providersProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.providerDoc.delete({ where: { id: input.id } });
}),
/** Отметить выплату (наличные / перевод). По умолчанию — весь долг */
payout: superProcedure
.input(z.object({ id: z.string(), amount: z.number().int().min(1), note: z.string().trim().max(300).optional() }))
.mutation(async ({ ctx, input }) => {
const now = ctx.svc.now();
const last = await ctx.db.payout.findFirst({ where: { providerId: input.id }, orderBy: { periodEnd: 'desc' } });
const first = await ctx.db.order.findFirst({ where: { providerId: input.id, ...DONE }, orderBy: { completedAt: 'asc' }, select: { completedAt: true } });
await ctx.db.payout.create({
data: { providerId: input.id, amount: input.amount, status: 'PAID', paidAt: now, note: input.note, createdById: ctx.admin.id, periodStart: last?.periodEnd ?? first?.completedAt ?? now, periodEnd: now },
});
}),
});
+3 -47
View File
@@ -1,9 +1,5 @@
import { defaultParams, discountFor, LOCALES } from '@barev/core';
import { rateOrder,
clientPoints,
pointsHistory,
redeemQuote,
requestRedemption,
import {
applyAgencyCode,
availableSlots,
cancelByClient,
@@ -31,9 +27,6 @@ import { rateOrder,
toggleRelocationDoc,
cardQrToken,
clientPartners,
clientConversation,
receiveMessage,
setClientChannel,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
@@ -58,8 +51,6 @@ export const clientRouter = router({
membership,
agencyName: agency?.name ?? null,
giftUsed: !!ctx.client.giftUsedAt,
/** барики: доступный баланс */
points: ctx.client.pointsActive,
cardCode: ctx.client.cardCode,
barevPlusPrice: settings['barevPlus.monthlyPrice'],
freeCancelHours: settings['cancel.freeHours'],
@@ -188,13 +179,8 @@ export const clientRouter = router({
/** ORD-4 */
quote: clientProcedure
.input(z.object({ serviceCode: z.string(), params: paramsInput, promoCode: z.string().trim().max(40).optional() }))
.query(async ({ ctx, input }) => {
const r = await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params, input.promoCode);
// барики: сколько можно списать в этом заказе (с промокодом — нельзя)
const points = await redeemQuote(ctx.db, ctx.client.id, r.quote.totalPrice, await getSettings(ctx.db));
return { ...r.quote, promo: r.promo ? { code: r.promo.code, discountPct: r.promo.discountPct } : null, points };
}),
.input(z.object({ serviceCode: z.string(), params: paramsInput }))
.query(async ({ ctx, input }) => (await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params)).quote),
/** ORD-3 */
slots: clientProcedure
@@ -218,10 +204,6 @@ export const clientRouter = router({
note: z.string().max(1000).optional(),
paymentMethodId: z.string().optional(),
repeatOfId: z.string().optional(),
/** промокод: применится, если выгоднее скидки подписки */
promoCode: z.string().trim().max(40).optional(),
/** оплатить часть бариками (с промокодом нельзя) */
usePoints: z.number().int().min(0).max(10_000_000).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -229,18 +211,6 @@ export const clientRouter = router({
return { id: order.id, number: order.number };
}),
/** Барики: экран, история, запрос на оплату заказа вне приложения или уже созданного заказа */
points: router({
summary: clientProcedure.query(({ ctx }) => clientPoints(ctx.svc, ctx.client.id)),
history: clientProcedure.input(z.object({ before: z.date().optional() }).optional()).query(({ ctx, input }) => pointsHistory(ctx.db, ctx.client.id, { take: 30, before: input?.before })),
request: consentedClientProcedure
.input(z.object({ amount: z.number().int().min(1).max(10_000_000), orderId: z.string().optional(), note: z.string().trim().max(300).optional() }))
.mutation(async ({ ctx, input }) => {
const r = await requestRedemption(ctx.svc, { clientId: ctx.client.id, ...input });
return { id: r.id };
}),
}),
orders: router({
list: clientProcedure.query(({ ctx }) => listClientOrders(ctx.db, ctx.client.id)),
get: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => clientOrder(ctx.db, ctx.client.id, input.id)),
@@ -251,10 +221,6 @@ export const clientRouter = router({
reschedule: clientProcedure
.input(z.object({ id: z.string(), scheduledAt: z.date() }))
.mutation(({ ctx, input }) => rescheduleByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, scheduledAt: input.scheduledAt })),
/** Оценка после выполнения: звёзды и комментарий */
rate: consentedClientProcedure
.input(z.object({ id: z.string(), score: z.number().int().min(1).max(5), comment: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => rateOrder(ctx.svc, { orderId: input.id, clientId: ctx.client.id, score: input.score, comment: input.comment })),
/** ORD-7 */
repeat: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => repeatDraft(ctx.db, input.id, ctx.client.id)),
}),
@@ -266,14 +232,4 @@ export const clientRouter = router({
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'CLIENT', actorId: ctx.client.id } });
return { url: await signedUrl(file.storageKey) };
}),
/** Чат с командой: та же переписка, что в боте; ответы приходят и сюда, и в чат с ботом */
chat: router({
get: clientProcedure.query(({ ctx }) => clientConversation(ctx.svc, ctx.client.id)),
send: consentedClientProcedure.input(z.object({ text: z.string().trim().min(1).max(4000) })).mutation(async ({ ctx, input }) => {
const res = await receiveMessage(ctx.svc, { clientId: ctx.client.id }, { text: input.text, source: 'MINIAPP' });
return { id: res?.message.id ?? null, channel: res?.channel ?? null };
}),
setChannel: consentedClientProcedure.input(z.object({ channel: z.enum(['SUPPORT', 'CONCIERGE']) })).mutation(({ ctx, input }) => setClientChannel(ctx.svc, ctx.client.id, input.channel)),
}),
});
+3 -66
View File
@@ -16,38 +16,13 @@ import {
partnerCabinet,
redeemOffer,
removePartnerMember,
companyStaffCandidates,
exchangeProWebLogin,
getSettings,
partnerAddStaff,
partnerCabinetData,
partnerDeclineOrder,
partnerRemoveManager,
partnerResetStaffInvite,
partnerSaveSettings,
partnerUnassign,
partnerUpdateStaff,
revokeProWebSession,
} from '@barev/services';
import { LOCALES } from '@barev/core';
import { env } from '../env.ts';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { signedUrl } from '../infra.ts';
import { proUserProcedure, providerProcedure, publicProcedure, router } from '../trpc.ts';
import { proUserProcedure, providerProcedure, router } from '../trpc.ts';
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const botLink = (code: string) => `https://t.me/${env.PRO_BOT_USERNAME}?start=${code}`;
const staffInput = z.object({
name: z.string().trim().min(1).max(100),
phone: z.string().trim().max(30).nullable().optional(),
languages: z.array(z.enum(LOCALES)).min(1),
districtIds: z.array(z.string()).max(30),
schedule: z.array(z.object({ weekday: z.number().int().min(0).max(6), startMinute: z.number().int().min(0).max(1440), endMinute: z.number().int().min(0).max(1440) })).max(21),
maxOrdersPerDay: z.number().int().min(0).max(20).nullable().optional(),
maxHoursPerDay: z.number().int().min(0).max(24).nullable().optional(),
});
const co = z.object({ companyId: z.string() });
/** Кабинет партнёра клуба: собственник и сотрудники */
const partnerRouter = router({
@@ -75,7 +50,6 @@ export const proRouter = router({
: null,
companies: ctx.identity.companies.map((c) => ({ id: c.id, name: c.name })),
partners: ctx.identity.partners,
web: ctx.web,
};
}),
@@ -124,52 +98,15 @@ export const proRouter = router({
return { url: await signedUrl(file.storageKey) };
}),
/** Вход в кабинет из браузера: одноразовый код из бота → токен на 30 дней */
webLogin: publicProcedure.input(z.object({ code: z.string().min(10).max(100) })).mutation(async ({ ctx, input }) => ({ token: await exchangeProWebLogin(ctx.svc, input.code) })),
webLogout: publicProcedure.mutation(async ({ ctx }) => {
const auth = ctx.req.headers.authorization;
if (auth?.startsWith('pro ')) await revokeProWebSession(ctx.db, auth.slice(4));
}),
/** PRO-7: менеджер партнёра (диспетчер компании) */
/** PRO-7: диспетчер компании */
dispatcher: router({
board: proUserProcedure.input(z.object({ companyId: z.string() })).query(({ ctx, input }) => companyBoard(ctx.svc, input.companyId, ctx.identity.user.id)),
/** Кабинет: заказы, сотрудники, доход, настройки */
cabinet: proUserProcedure.input(co).query(async ({ ctx, input }) => {
const d = await partnerCabinetData(ctx.svc, input.companyId, ctx.identity.user.id);
return {
...d,
company: { ...d.company, managerInvite: undefined, managerLink: botLink(d.company.managerInvite) },
staff: d.staff.map((p) => ({ ...p, inviteCode: undefined, inviteLink: p.inviteCode ? botLink(p.inviteCode) : null })),
};
}),
staffForOrder: proUserProcedure.input(z.object({ companyId: z.string(), orderId: z.string() })).query(async ({ ctx, input }) => {
if (!ctx.identity.companies.some((c) => c.id === input.companyId)) throw new TRPCError({ code: 'FORBIDDEN' });
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, select: { companyId: true } });
if (order?.companyId !== input.companyId) throw new TRPCError({ code: 'NOT_FOUND' });
return companyStaffCandidates(ctx.db, input.companyId, input.orderId, await getSettings(ctx.db));
return companyStaffForOrder(ctx.svc, input.companyId, input.orderId);
}),
assign: proUserProcedure
.input(z.object({ companyId: z.string(), orderId: z.string(), providerId: z.string() }))
.mutation(({ ctx, input }) => dispatcherAssign(ctx.svc, { ...input, dispatcherUserId: ctx.identity.user.id })),
/** Отказаться от заказа — он вернётся оператору */
decline: proUserProcedure.input(co.extend({ orderId: z.string() })).mutation(({ ctx, input }) => partnerDeclineOrder(ctx.svc, { ...input, userId: ctx.identity.user.id })),
/** Снять сотрудника с заказа (переназначить) */
unassign: proUserProcedure.input(co.extend({ orderId: z.string() })).mutation(({ ctx, input }) => partnerUnassign(ctx.svc, { ...input, userId: ctx.identity.user.id })),
addStaff: proUserProcedure.input(co.extend({ staff: staffInput })).mutation(async ({ ctx, input }) => {
const r = await partnerAddStaff(ctx.svc, { companyId: input.companyId, userId: ctx.identity.user.id, staff: input.staff });
return { id: r.id, inviteLink: botLink(r.inviteCode) };
}),
updateStaff: proUserProcedure
.input(co.extend({ providerId: z.string(), staff: staffInput, active: z.boolean().optional() }))
.mutation(({ ctx, input }) => partnerUpdateStaff(ctx.svc, { ...input, userId: ctx.identity.user.id })),
resetStaffInvite: proUserProcedure.input(co.extend({ providerId: z.string() })).mutation(async ({ ctx, input }) => {
const r = await partnerResetStaffInvite(ctx.svc, { ...input, userId: ctx.identity.user.id });
return { inviteLink: botLink(r.inviteCode) };
}),
saveSettings: proUserProcedure
.input(co.extend({ autoAssign: z.boolean(), travelGapMinutes: z.number().int().min(0).max(240) }))
.mutation(({ ctx, input }) => partnerSaveSettings(ctx.svc, { ...input, userId: ctx.identity.user.id })),
removeManager: proUserProcedure.input(co.extend({ managerId: z.string() })).mutation(({ ctx, input }) => partnerRemoveManager(ctx.svc, { ...input, userId: ctx.identity.user.id })),
}),
});
+3 -7
View File
@@ -9,9 +9,6 @@ import { publicProcedure, router } from '../trpc.ts';
export const publicRouter = router({
/** LND-5: цены «от» и скидки — для лендинга */
/** Тексты, изменённые в админке, — Mini App подставляет их поверх своих переводов */
texts: publicProcedure.query(({ ctx }) => ctx.db.textOverride.findMany({ select: { key: true, locale: true, value: true } })),
catalog: publicProcedure.query(async ({ ctx }) => {
const [categories, settings] = await Promise.all([listCatalog(ctx.db), getSettings(ctx.db)]);
return {
@@ -83,10 +80,9 @@ export const publicRouter = router({
.input(
z.object({
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))])),
// обязательны только параметры уборки и телефон; остальное бот доспросит или уточнит оператор
preferredAt: z.iso.datetime().transform((v) => new Date(v)).optional(),
districtCode: z.string().max(40).optional(),
addressText: z.string().max(300).optional(),
preferredAt: z.iso.datetime().transform((v) => new Date(v)),
districtCode: z.string().max(40),
addressText: z.string().max(300),
note: z.string().max(1000).optional(),
phone: z.string().max(30),
name: z.string().max(80).optional(),
+4 -217
View File
@@ -1,24 +1,16 @@
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import multipart, { type MultipartFile } from '@fastify/multipart';
import multipart from '@fastify/multipart';
import * as Sentry from '@sentry/node';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { getFlow } from '@barev/core';
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffChecklistItem, staffSend, startTextOverrides } from '@barev/services';
import { getFile, putFile } from '@barev/services/storage';
import { TelegramSender } from '@barev/services/telegram';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { ADMIN_COOKIE, findSession } from './auth/admin.ts';
import { DomainError, proIdentity, setChecklistItem } from '@barev/services';
import Fastify from 'fastify';
import { validateInitData } from './auth/telegram.ts';
import { env } from './env.ts';
import { ctx as svc, db, ensureBucket, putObject } from './infra.ts';
import { appRouter, type AppRouter } from './router.ts';
import { channelsFor } from './routers/backoffice/chats.ts';
import { createContext, type StaffRole } from './trpc.ts';
/** Скачивание файлов, присланных ботам, — по их токенам */
const clientBotFiles = new TelegramSender(env.CLIENT_BOT_TOKEN, 'client-bot');
const proBotFiles = new TelegramSender(env.PRO_BOT_TOKEN, 'pro-bot');
import { createContext } from './trpc.ts';
if (env.SENTRY_DSN) Sentry.init({ dsn: env.SENTRY_DSN, environment: env.NODE_ENV });
@@ -90,208 +82,6 @@ app.post('/files/pro/checklist-photo', async (req, reply) => {
return { fileId: file.id };
});
// ───── файлы в чатах ─────
/** Что можно прислать в чат: картинки, PDF, офисные документы, текст, архив */
const CHAT_TYPES = /^(image\/(jpeg|png|webp|gif|heic)|application\/pdf|application\/msword|application\/vnd\.openxmlformats-officedocument\.[\w.]+|application\/vnd\.ms-excel|text\/plain|application\/zip)$/;
/** Сотрудник новой админки по cookie-сессии и каналы, которые ему доступны */
async function staffFromCookie(req: FastifyRequest) {
const session = await findSession(db, req.cookies[ADMIN_COOKIE]);
if (!session?.verified || session.adminUser.roles.length === 0) return null;
const roles = session.adminUser.roles as StaffRole[];
return { admin: session.adminUser, channels: channelsFor(roles, roles.includes('SUPERADMIN')) };
}
function multipartField(part: MultipartFile, name: string) {
const f = part.fields[name];
return f && !Array.isArray(f) && f.type === 'field' ? String(f.value) : undefined;
}
/** Имя файла как пришло, но без путей и управляющих символов */
const safeName = (name: string | undefined) => (name ?? 'file').replace(/[\\/\u0000-\u001f]/g, '_').slice(-120) || 'file';
/** Команда отправляет файл в диалог (или во внутреннюю заметку) */
app.post<{ Params: { threadId: string } }>('/files/bo/chat/:threadId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff) return reply.code(401).send({ error: 'ADMIN_LOGIN_REQUIRED' });
const thread = await db.chatThread.findUnique({ where: { id: req.params.threadId } });
if (!thread) return reply.code(404).send({ error: 'NOT_FOUND' });
if (!staff.channels.includes(thread.channel)) return reply.code(403).send({ error: 'ROLE_FORBIDDEN' });
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(`chats/${thread.id}`, body, part.mimetype, fileName);
try {
const m = await staffSend(svc, {
threadId: thread.id,
adminUserId: staff.admin.id,
text: multipartField(part, 'text') ?? '',
internal: multipartField(part, 'internal') === 'true',
file: { fileKey, fileName, mime: part.mimetype, size: body.length },
});
await db.auditLog.create({ data: { adminUserId: staff.admin.id, action: 'bo.chats.sendFile', entity: 'chats', entityId: thread.id, data: { fileName, size: body.length } } });
return { id: m.id };
} catch (e) {
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
throw e;
}
});
/** Отдать файл сообщения: из нашего хранилища или из Telegram (бот скачивает по file_id) */
async function sendChatFile(reply: FastifyReply, m: { fileKey: string | null; tgFileId: string | null; tgBot: string | null; mime: string | null; fileName: string | null }) {
reply.header('cache-control', 'private, max-age=3600');
reply.header('content-type', m.mime ?? 'application/octet-stream');
reply.header('content-disposition', `inline; filename*=UTF-8''${encodeURIComponent(m.fileName ?? 'file')}`);
reply.header('x-content-type-options', 'nosniff');
if (m.fileKey) return reply.send(await getFile(m.fileKey));
if (m.tgFileId) {
const res = await (m.tgBot === 'pro' ? proBotFiles : clientBotFiles).download(m.tgFileId);
if (!res?.body) return reply.code(404).send({ error: 'FILE_UNAVAILABLE' });
return reply.send(Buffer.from(await res.arrayBuffer()));
}
return reply.code(404).send({ error: 'NOT_FOUND' });
}
app.get<{ Params: { messageId: string } }>('/files/bo/chat-file/:messageId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff) return reply.code(401).send({ error: 'ADMIN_LOGIN_REQUIRED' });
const m = await db.conciergeMessage.findUnique({ where: { id: req.params.messageId }, include: { thread: { select: { channel: true } } } });
if (!m?.thread || !staff.channels.includes(m.thread.channel)) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, m);
});
// ───── исполнители и заказы (этап 3) ─────
/** Заказы и исполнителей ведёт поддержка; суперадмину — всё */
const canDispatch = (staff: NonNullable<Awaited<ReturnType<typeof staffFromCookie>>>) => staff.admin.roles.includes('SUPERADMIN') || staff.admin.roles.includes('SUPPORT');
const DOC_TYPES = /^(image\/(jpeg|png|webp|heic)|application\/pdf)$/;
/** Документ для допуска исполнителя: паспорт, договор… (фото или PDF) */
app.post<{ Params: { providerId: string } }>('/files/bo/provider-doc/:providerId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(staff ? 403 : 401).send({ error: staff ? 'ROLE_FORBIDDEN' : 'ADMIN_LOGIN_REQUIRED' });
const provider = await db.provider.findUnique({ where: { id: req.params.providerId } });
if (!provider) return reply.code(404).send({ error: 'NOT_FOUND' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
if (!DOC_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(`providers/${provider.id}`, body, part.mimetype, fileName);
const title = (multipartField(part, 'title') ?? '').trim().slice(0, 120) || fileName;
const doc = await db.providerDoc.create({ data: { providerId: provider.id, title, fileKey, fileName, mime: part.mimetype, size: body.length, uploadedById: staff.admin.id } });
await db.auditLog.create({ data: { adminUserId: staff.admin.id, action: 'bo.providers.addDoc', entity: 'providers', entityId: provider.id, data: { title } } });
return { id: doc.id };
});
/** Команда загружает фото к пункту чек-листа за исполнителя и отмечает пункт */
app.post<{ Params: { orderId: string } }>('/files/bo/checklist-photo/:orderId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(staff ? 403 : 401).send({ error: staff ? 'ROLE_FORBIDDEN' : 'ADMIN_LOGIN_REQUIRED' });
const order = await db.order.findUnique({ where: { id: req.params.orderId } });
if (!order) return reply.code(404).send({ error: 'NOT_FOUND' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
const code = multipartField(part, 'code');
if (!code) return reply.code(400).send({ error: 'BAD_INPUT' });
if (!IMAGE_TYPES.has(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const key = await putObject(`orders/${order.id}`, body, part.mimetype);
const file = await db.orderFile.create({ data: { orderId: order.id, kind: 'PHOTO_REPORT', storageKey: key, mime: part.mimetype, size: body.length, uploadedBy: 'ADMIN', uploadedById: staff.admin.id } });
try {
await staffChecklistItem(svc, { orderId: order.id, adminId: staff.admin.id, code, checked: true, fileId: file.id });
} catch (e) {
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
throw e;
}
return { fileId: file.id };
});
app.get<{ Params: { docId: string } }>('/files/bo/provider-doc-file/:docId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
const d = await db.providerDoc.findUnique({ where: { id: req.params.docId } });
if (!d) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, { fileKey: d.fileKey, tgFileId: null, tgBot: null, mime: d.mime, fileName: d.fileName });
});
/** Фотоотчёт по заказу; каждый просмотр — в журнал доступа */
app.get<{ Params: { fileId: string } }>('/files/bo/order-file/:fileId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
const f = await db.orderFile.findUnique({ where: { id: req.params.fileId } });
if (!f || f.deletedAt) return reply.code(404).send({ error: 'NOT_FOUND' });
await db.fileAccessLog.create({ data: { fileId: f.id, actorType: 'ADMIN', actorId: staff.admin.id } });
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;
const init = validateInitData(auth, env.clientBotToken, env.TG_INIT_DATA_MAX_AGE);
return init.ok ? getClientByTelegramId(db, BigInt(init.user.id)) : Promise.resolve(null);
}
app.get<{ Params: { messageId: string } }>('/files/client/chat-file/:messageId', async (req, reply) => {
const client = await clientFromInit(req);
if (!client) return reply.code(401).send({ error: 'UNAUTHORIZED' });
const m = await db.conciergeMessage.findUnique({ where: { id: req.params.messageId } });
if (!m || m.clientId !== client.id || m.internal) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, m);
});
app.post('/files/client/chat', async (req, reply) => {
const client = await clientFromInit(req);
if (!client) return reply.code(401).send({ error: 'UNAUTHORIZED' });
if (!client.consentAt) return reply.code(412).send({ error: 'CONSENT_REQUIRED' });
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(`chats/client-${client.id}`, body, part.mimetype, fileName);
const res = await receiveMessage(svc, { clientId: client.id }, { text: multipartField(part, 'text') ?? '', source: 'MINIAPP', file: { fileKey, fileName, mime: part.mimetype, size: body.length } });
return { id: res?.message.id ?? null };
});
/** Ошибки из Mini App и админки (sendBeacon) — только в лог, без хранения */
app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
const b = (typeof req.body === 'object' && req.body) || {};
@@ -301,9 +91,6 @@ app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
app.get('/health', async () => ({ ok: true }));
// тексты из админки поверх переводов (обновляются раз в минуту)
startTextOverrides(db);
await ensureBucket().catch((e) => app.log.warn({ err: e }, 'S3 недоступно — загрузка фото работать не будет'));
await app.listen({ port: env.API_PORT, host: '0.0.0.0' });
console.log(`API: http://localhost:${env.API_PORT}`);
+2 -11
View File
@@ -2,7 +2,7 @@
import type {} from '@fastify/cookie';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { initTRPC, TRPCError } from '@trpc/server';
import { DomainError, getClientByTelegramId, proIdentity, proWebSessionUser, startClient } from '@barev/services';
import { DomainError, getClientByTelegramId, proIdentity, startClient } from '@barev/services';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { ADMIN_COOKIE, findSession } from './auth/admin.ts';
@@ -86,21 +86,12 @@ export const consentedClientProcedure = clientProcedure.use(async ({ ctx, next }
/** Mini App исполнителя: initData бота исполнителей; пользователь должен быть привязан инвайтом */
export const proUserProcedure = publicProcedure.use(async ({ ctx, next }) => {
// кабинет менеджера партнёра в браузере: токен вместо initData (вход по одноразовой ссылке из бота)
const auth = ctx.req.headers.authorization;
if (auth?.startsWith('pro ')) {
const telegramId = await proWebSessionUser(ctx.db, auth.slice(4), ctx.svc.now());
if (!telegramId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'WEB_SESSION_EXPIRED' });
const identity = await proIdentity(ctx.db, telegramId);
if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' });
return next({ ctx: { tgUser: { id: Number(telegramId), first_name: identity.user.firstName ?? '' }, identity, web: true } });
}
const init = checkInitData(ctx.req, env.proBotToken);
const identity = await proIdentity(ctx.db, BigInt(init.user.id));
if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' });
}
return next({ ctx: { tgUser: init.user, identity, web: false } });
return next({ ctx: { tgUser: init.user, identity } });
});
export const providerProcedure = proUserProcedure.use(async ({ ctx, next }) => {
-2
View File
@@ -14,14 +14,12 @@
"@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",
+10 -66
View File
@@ -10,20 +10,6 @@ import { Clients } from './pages/Clients.tsx';
import { ClientCard } from './pages/ClientCard.tsx';
import { Team } from './pages/Team.tsx';
import { Soon } from './pages/Soon.tsx';
import { Chats } from './pages/Chats.tsx';
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';
import { ServicePartnerCard, ServicePartners } from './pages/ServicePartners.tsx';
import { Points } from './pages/Points.tsx';
import { Products, ServiceEditor } from './pages/Products.tsx';
import { Broadcasts, Journal, Settings } from './pages/Admin5.tsx';
import { MoveCard, Moves, Requests } from './pages/Moves.tsx';
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
export interface Me {
@@ -56,18 +42,15 @@ interface NavItem {
const NAV: NavItem[] = [
{ path: '/', key: 'nav.dashboard', icon: 'dashboard', roles: 'all' },
{ path: '/clients', key: 'nav.clients', icon: 'clients', roles: ['SUPPORT', 'CONCIERGE'] },
{ path: '/chats', key: 'nav.chats', icon: 'chats', roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
{ path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT', 'CONCIERGE'], count: 'orders' },
{ path: '/providers', key: 'nav.providers', icon: 'providers', roles: ['SUPPORT'], group: 'work' },
{ path: '/points', key: 'nav.points', icon: 'gift', roles: [], group: 'work' },
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', roles: [], group: 'partners' },
{ path: '/service-partners', key: 'nav.servicePartners', icon: 'providers', roles: [], group: 'partners' },
{ path: '/venues', key: 'nav.venues', icon: 'venues', roles: [], group: 'partners' },
{ path: '/crm', key: 'nav.crm', icon: 'crm', roles: ['SALES'], group: 'partners' },
{ path: '/products', key: 'nav.products', icon: 'products', roles: ['SUPPORT'], group: 'setup' },
{ path: '/journal', key: 'nav.journal', icon: 'journal', roles: [], group: 'setup' },
{ path: '/chats', key: 'nav.chats', icon: 'chats', stage: 2, roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
{ path: '/orders', key: 'nav.orders', icon: 'orders', stage: 3, roles: ['SUPPORT'], count: 'orders' },
{ path: '/providers', key: 'nav.providers', icon: 'providers', stage: 3, 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: '/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' },
{ path: '/settings', key: 'nav.settings', icon: 'gear', roles: [], group: 'setup' },
];
const GROUP_KEY = { work: 'nav.groupWork', partners: 'nav.groupPartners', setup: 'nav.groupSetup' } as const;
@@ -88,57 +71,18 @@ function Shell({ me }: { me: Me }) {
const current = items.find((n) => n.path === path || (n.path !== '/' && path.startsWith(`${n.path}/`)));
let page: ReactNode;
const clientId = match('/clients/:id', path)?.id;
const threadId = match('/chats/:id', path)?.id;
const canChat = items.some((n) => n.path === '/chats');
const orderId = match('/orders/:id', path)?.id;
const providerId = match('/providers/:id', path)?.id;
// заказы и исполнители — поддержка; переезды и заявки — ещё и консьерж
const canOrders = isSuper || me.roles.includes('SUPPORT');
const canMoves = items.some((n) => n.path === '/orders');
const moveId = match('/moves/:id', path)?.id;
const leadId = match('/crm/:id', path)?.id;
const agencyId = match('/agencies/:id', path)?.id;
const venueId = match('/venues/:id', path)?.id;
const servicePartnerId = match('/service-partners/:id', path)?.id;
const canCrm = items.some((n) => n.path === '/crm');
const productId = match('/products/:id', path)?.id;
const canProducts = items.some((n) => n.path === '/products');
const canBroadcast = isSuper || me.roles.includes('SUPPORT');
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 ((path === '/orders' || path === '/moves') && canMoves) page = <Moves me={me} />;
else if (moveId && canMoves) page = <MoveCard id={moveId} me={me} />;
else if (path === '/requests' && canMoves) page = <Requests 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 === '/points' && isSuper) page = <Points />;
else if (path === '/service-partners' && isSuper) page = <ServicePartners />;
else if (servicePartnerId && isSuper) page = <ServicePartnerCard id={servicePartnerId} />;
else if (path === '/venues' && isSuper) page = <Venues />;
else if (venueId && isSuper) page = <VenueCard id={venueId} />;
else if (path === '/products' && canProducts) page = <Products me={me} />;
else if (productId && canProducts) page = <ServiceEditor id={productId} me={me} />;
else if (path === '/broadcasts' && canBroadcast) page = <Broadcasts />;
else if (path === '/journal' && isSuper) page = <Journal />;
else if (path === '/settings' && isSuper) page = <Settings />;
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} />;
else if (current?.stage) page = <Soon title={t(current.key)} stage={current.stage} />;
else page = <Dashboard me={me} />;
const isActive = (n: NavItem) =>
n.path === '/' ? path === '/' : path === n.path || path.startsWith(`${n.path}/`) || (n.path === '/orders' && (path.startsWith('/moves') || path === '/requests'));
const isActive = (n: NavItem) => (n.path === '/' ? path === '/' : path === n.path || path.startsWith(`${n.path}/`));
const tabItems = items.slice(0, 4);
return (
<div className={`bo-layout${path.startsWith('/chats') ? ' bo-layout--chats' : ''}${threadId ? ' bo-layout--thread' : ''}`}>
<div className="bo-layout">
<aside className="bo-side">
<div className="bo-side__logo">
<Logo size={22} color="#f6e9dc" />
-162
View File
@@ -1,162 +0,0 @@
/* Этап 5: продукты, рассылки, журнал, настройки */
/* текст на 5 языках */
.bo-l5 {
display: grid;
gap: 6px;
}
.bo-l5__row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: start;
gap: 8px;
}
.bo-l5__lang {
padding-top: 11px;
font-family: var(--bc-font-head);
font-size: 12px;
font-weight: 700;
color: var(--bc-ink-3);
}
.bo-l5 .bo-input::placeholder,
.bo-l5 .bo-textarea::placeholder {
color: var(--bc-ink-3);
}
/* таблица цен */
.bo-tablewrap {
overflow-x: auto;
margin: 0 -6px;
}
.bo-ptab th {
white-space: nowrap;
font-size: 12px;
}
.bo-ptab td {
padding: 4px 6px;
}
.bo-ptab .bo-input {
min-width: 78px;
min-height: 36px;
padding: 4px 10px;
text-align: right;
}
.bo-promo {
font-family: var(--bc-font-head);
font-weight: 700;
letter-spacing: 0.04em;
padding: 4px 10px;
border-radius: 10px;
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
/* рассылка */
.bo-bcwho {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 16px;
background: var(--bc-blue);
color: var(--bc-blue-ink);
}
/* журнал */
.bo-journal {
list-style: none;
margin: 0;
padding: 0;
}
.bo-journal li {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-top: 1px solid var(--bc-line);
font-size: 14px;
}
.bo-journal li:first-child {
border-top: 0;
}
.bo-journal .bo-feed__time {
width: 110px;
flex: none;
padding-top: 4px;
}
.bo-json {
margin: 8px 0 0;
padding: 10px 12px;
border-radius: 12px;
background: var(--bo-soft);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
max-height: 240px;
overflow: auto;
}
/* настройки */
.bo-settings {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 14px;
align-items: start;
}
.bo-settings .bo-input[data-changed] {
border-color: var(--bc-accent);
background: #fff6f9;
}
@media (max-width: 860px) {
.bo-journal .bo-feed__time {
width: 80px;
}
.bo-settings {
grid-template-columns: minmax(0, 1fr);
}
}
/* вопрос клиенту в редакторе услуги */
.bo-qfield {
padding: 12px;
border-radius: 16px;
background: var(--bo-soft);
}
/* ───── барики ───── */
.bo-pt-tabs {
margin-bottom: 16px;
overflow-x: auto;
}
.bo-pt-tabs button {
flex: none;
padding: 0 14px;
}
.bo-pt-pick {
cursor: pointer;
border-radius: 14px;
}
.bo-pt-pick[data-on] {
background: var(--bo-soft);
}
.bo-pt-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 8px;
}
.bo-pt-filters .bo-select,
.bo-pt-filters .bo-input {
width: auto;
min-width: 140px;
}
.bo-table tr[data-hl] td {
background: color-mix(in srgb, var(--bc-perks) 45%, transparent);
}
.bo-alert--warn {
background: var(--bc-perks);
color: var(--bc-perks-ink);
cursor: default;
}
-829
View File
@@ -1,829 +0,0 @@
/*
* Чаты (этап 2). Три колонки: список диалогов · переписка · о клиенте.
* Переписка — на кремовом фоне: клиент слева белым, команда справа сливовым, заметки — жёлтым «стикером».
*/
/* страница чатов занимает ровно экран: прокручиваются список и лента, а не страница */
.bo-layout--chats .bo-main {
padding-top: 20px;
padding-bottom: 20px;
height: 100dvh;
display: flex;
flex-direction: column;
}
.bo-layout--chats .bo-banner {
margin-bottom: 14px;
}
.bo-chat {
flex: 1;
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
gap: 14px;
min-height: 0;
}
.bo-chat--side {
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr) 300px;
}
.bo-chat__list,
.bo-chat__conv,
.bo-chat__side {
background: var(--bo-panel);
border-radius: var(--bo-r-panel);
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.bo-chat__listhead {
padding: 18px 16px 10px;
display: flex;
flex-direction: column;
gap: 12px;
border-bottom: 1px solid var(--bc-line);
}
.bo-chat__title {
font-size: 30px;
}
.bo-chtabs {
display: flex;
padding: 4px;
gap: 4px;
border-radius: var(--bc-pill);
background: var(--bo-soft);
}
.bo-chtabs button {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 34px;
border: 0;
border-radius: var(--bc-pill);
background: none;
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--bc-ink-2);
cursor: pointer;
}
.bo-chtabs button[aria-pressed='true'] {
background: #fff;
color: var(--bc-ink);
box-shadow: 0 2px 8px rgba(30, 27, 26, 0.08);
}
.bo-chat__dot {
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: var(--bc-pill);
background: var(--bc-accent);
color: #fff;
font-size: 11px;
font-weight: 700;
display: inline-grid;
place-items: center;
}
.bo-chat__search {
flex: none;
max-width: none;
}
.bo-chat__search .bo-input {
background: var(--bo-soft);
min-height: 40px;
}
.bo-chat__filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.bo-chat__filters .bo-pill {
height: 30px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
}
.bo-chat__pillnum {
font-weight: 700;
color: var(--bc-accent);
}
.bo-pill[aria-pressed='true'] .bo-chat__pillnum {
color: var(--bc-pink);
}
.bo-chat__items {
list-style: none;
margin: 0;
padding: 6px;
overflow-y: auto;
flex: 1;
}
.bo-chat__none {
padding: 32px 20px;
text-align: center;
color: var(--bc-ink-2);
}
.bo-thread {
display: flex;
gap: 12px;
padding: 12px 10px;
border-radius: 18px;
color: inherit;
text-decoration: none;
}
.bo-thread:hover {
background: var(--bo-soft);
}
.bo-thread[aria-current='true'] {
background: var(--bo-soft);
box-shadow: inset 3px 0 0 var(--bc-accent);
}
.bo-thread[data-closed] {
opacity: 0.62;
}
.bo-thread__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.bo-thread__top {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.bo-thread__top .bo-plan {
height: 20px;
padding: 0 7px;
font-size: 11px;
}
.bo-thread__name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.bo-thread__time {
margin-left: auto;
font-size: 12px;
color: var(--bc-ink-3);
white-space: nowrap;
}
.bo-thread__last {
font-size: 14px;
color: var(--bc-ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-thread__meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 3px;
min-height: 20px;
min-width: 0;
}
.bo-thread__meta .bo-nav__count {
margin-left: 0;
flex: none;
}
.bo-thread__who {
font-size: 12px;
color: var(--bc-ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.bo-thread__who--none {
color: var(--bc-ink-3);
}
.bo-chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 20px;
padding: 0 8px;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
background: var(--bo-soft);
color: var(--bc-ink-2);
}
.bo-chip--SUPPORT {
background: var(--bc-blue);
color: var(--bc-blue-ink);
}
.bo-chip--CONCIERGE {
background: var(--bc-pink);
color: var(--bc-plum);
}
.bo-chip--provider {
background: var(--bc-docs);
color: var(--bc-docs-ink);
}
.bo-wait-chip {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: var(--bc-pill);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.bo-wait-chip--calm {
background: var(--bc-home);
color: var(--bc-home-ink);
}
.bo-wait-chip--warm {
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
.bo-wait-chip--hot {
background: var(--bc-accent);
color: #fff;
}
/* пустое состояние справа */
.bo-chat__pick {
margin: auto;
padding: 32px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.bo-chat__pickicon {
width: 64px;
height: 64px;
border-radius: 22px;
display: grid;
place-items: center;
background: var(--bc-pink);
color: var(--bc-plum);
margin-bottom: 8px;
}
/* шапка переписки */
.bo-chat__conv {
container-type: inline-size;
}
.bo-conv__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--bc-line);
}
@container (max-width: 640px) {
.bo-composer__hint {
display: none;
}
}
.bo-conv__back {
display: none;
color: var(--bc-ink);
}
.bo-conv__who {
min-width: 0;
flex: 1 1 180px;
}
.bo-conv__name {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 18px;
white-space: nowrap;
min-width: 0;
}
.bo-conv__name > span:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.bo-conv__sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 13px;
color: var(--bc-ink-3);
margin-top: 3px;
}
.bo-conv__actions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.bo-iconbtn {
width: 36px;
height: 36px;
border-radius: 12px;
border: 0;
background: none;
color: var(--bc-ink-2);
display: inline-grid;
place-items: center;
cursor: pointer;
flex: none;
}
.bo-iconbtn:hover:not(:disabled),
.bo-iconbtn[aria-expanded='true'],
.bo-iconbtn[aria-pressed='true'] {
background: var(--bo-soft);
color: var(--bc-ink);
}
.bo-iconbtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.bo-assignee {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 12px 0 4px;
border-radius: var(--bc-pill);
border: 1.5px solid var(--bc-line);
background: none;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.bo-assignee .bo-avatar {
font-size: 10px;
}
.bo-conv__banner {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
font-size: 14px;
background: var(--bc-blue);
color: var(--bc-blue-ink);
}
.bo-conv__banner--bot {
background: var(--bc-learn);
color: var(--bc-learn-ink);
}
/* исполнитель: текущий заказ в шапке и заказ над каждым его сообщением */
.bo-conv__banner--order {
background: var(--bc-docs);
color: var(--bc-docs-ink);
align-items: flex-start;
}
.bo-conv__banner--order.is-free {
background: var(--bo-soft);
color: var(--bc-ink);
}
.bo-conv__banner--order svg {
flex: none;
margin-top: 2px;
}
.bo-conv__next {
display: block;
font-size: 13px;
opacity: 0.75;
}
.bo-ordertag {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
margin: -2px 0 6px;
padding: 3px 9px;
border-radius: var(--bc-pill);
background: var(--bc-docs);
color: var(--bc-docs-ink);
font-size: 12px;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-ordertag--none {
background: var(--bo-soft);
color: var(--bc-ink-2);
}
.bo-conv__closed {
padding: 8px 18px;
font-size: 13px;
color: var(--bc-ink-2);
background: var(--bc-bg);
text-align: center;
}
/* лента сообщений */
.bo-msgs {
flex: 1;
overflow-y: auto;
background: var(--bc-bg);
padding: 16px clamp(12px, 3vw, 28px) 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
.bo-msgs__day {
align-self: center;
margin: 12px 0 6px;
padding: 3px 12px;
border-radius: var(--bc-pill);
background: #fff;
font-size: 12px;
font-weight: 600;
color: var(--bc-ink-3);
}
.bo-msg {
max-width: min(560px, 82%);
padding: 10px 14px 8px;
border-radius: 20px;
font-size: 15px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.bo-msg--in {
align-self: flex-start;
background: #fff;
border-bottom-left-radius: 6px;
box-shadow: 0 1px 0 var(--bc-line);
}
.bo-msg--out {
align-self: flex-end;
background: var(--bc-plum);
color: #f6e9dc;
border-bottom-right-radius: 6px;
}
.bo-msg--note {
align-self: flex-end;
background: #fbefbd;
color: var(--bc-perks-ink);
border: 1.5px dashed rgba(74, 16, 40, 0.22);
border-bottom-right-radius: 6px;
}
.bo-msg__text {
white-space: pre-wrap;
}
.bo-msg__label {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
margin-bottom: 4px;
opacity: 0.75;
}
.bo-msg__meta {
margin-top: 4px;
font-size: 11px;
opacity: 0.6;
text-align: right;
}
.bo-msg--in .bo-msg__meta {
text-align: left;
}
.bo-msg__err {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 5px 8px;
border-radius: 10px;
background: #fff;
color: var(--bc-danger);
font-size: 12px;
font-weight: 500;
}
.bo-msg__img {
display: block;
margin: -4px -8px 6px;
}
.bo-msg__img img {
display: block;
max-width: 100%;
max-height: 280px;
border-radius: 14px;
object-fit: cover;
}
.bo-msg__file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px 8px 8px;
margin: 0 -6px 6px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.14);
color: inherit;
text-decoration: none;
}
.bo-msg--in .bo-msg__file,
.bo-msg--note .bo-msg__file {
background: var(--bo-soft);
}
.bo-msg__file svg {
flex: none;
}
.bo-msg__fname {
display: block;
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-sys {
align-self: center;
font-size: 12px;
color: var(--bc-ink-2);
text-align: center;
padding: 2px 8px;
}
/* поле ответа */
.bo-composer {
padding: 8px 14px 14px;
border-top: 1px solid var(--bc-line);
background: #fff;
}
.bo-composer__tabs {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
}
.bo-composer__tabs [role='tab'] {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 12px;
border: 0;
border-radius: var(--bc-pill);
background: none;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--bc-ink-3);
cursor: pointer;
}
.bo-composer__tabs [role='tab'][aria-selected='true'] {
background: var(--bc-ink);
color: #fff;
}
.bo-composer--note .bo-composer__tabs [role='tab'][aria-selected='true'] {
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
.bo-composer__tabs [role='tab']:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.bo-composer__hint {
font-size: 12px;
color: var(--bc-ink-3);
}
.bo-composer__box {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 6px 6px 6px 14px;
border-radius: 22px;
border: 1.5px solid var(--bc-line);
background: #fff;
}
.bo-composer__box:focus-within {
border-color: var(--bc-accent);
}
.bo-composer--note .bo-composer__box {
background: #fdf6d8;
border-style: dashed;
border-color: rgba(74, 16, 40, 0.25);
}
.bo-composer__input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
resize: none;
background: none;
font: inherit;
font-size: 15px;
line-height: 1.45;
padding: 7px 0;
max-height: 220px;
}
.bo-composer__tools {
display: flex;
align-items: center;
gap: 2px;
}
/* всплывающие меню */
.bo-pop-anchor {
position: relative;
display: inline-flex;
}
.bo-pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 50;
min-width: 220px;
max-width: min(340px, 86vw);
padding: 6px;
border-radius: 18px;
background: #fff;
box-shadow:
0 18px 50px rgba(30, 27, 26, 0.18),
0 0 0 1px var(--bc-line);
display: flex;
flex-direction: column;
gap: 2px;
}
.bo-pop--up {
top: auto;
bottom: calc(100% + 8px);
}
.bo-pop__item {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 6px 12px;
border: 0;
border-radius: 12px;
background: none;
font: inherit;
font-size: 14px;
text-align: left;
color: var(--bc-ink);
cursor: pointer;
}
.bo-pop__item:hover {
background: var(--bo-soft);
}
.bo-pop__item[aria-current='true'] {
color: var(--bc-ink-3);
}
.bo-tpl {
width: 340px;
padding: 10px;
gap: 8px;
}
.bo-tpl__list {
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.bo-tpl__item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 0;
border-radius: 12px;
background: none;
text-align: left;
font: inherit;
font-size: 14px;
cursor: pointer;
color: var(--bc-ink);
}
.bo-tpl__item span {
font-size: 13px;
color: var(--bc-ink-2);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bo-tpl__item:hover {
background: var(--bo-soft);
}
/* о клиенте */
.bo-chat__side {
padding: 16px 18px 20px;
overflow-y: auto;
}
.bo-side-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.bo-minipass {
display: flex;
flex-direction: column;
gap: 2px;
padding: 14px 16px;
border-radius: 20px;
background: var(--bo-soft);
font-size: 13px;
}
.bo-minipass__plan {
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 20px;
}
.bo-minipass--BAREV_PLUS {
background: var(--bc-pink);
color: var(--bc-plum);
}
.bo-minipass--ELITE {
background: var(--bc-plum);
color: var(--bc-perks);
}
.bo-side-panel__note {
margin: 0;
padding: 10px 12px;
border-radius: 14px;
background: #fbefbd;
color: var(--bc-perks-ink);
font-size: 13px;
white-space: pre-line;
}
.bo-side-panel__orders {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
font-size: 14px;
}
/* узкий десктоп: «о клиенте» — выезжающая панель поверх */
@media (max-width: 1559px) {
.bo-chat--side {
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
}
.bo-chat__side {
position: fixed;
top: 12px;
right: 12px;
bottom: 12px;
width: min(340px, calc(100vw - 24px));
z-index: 45;
box-shadow: 0 30px 80px rgba(30, 27, 26, 0.28);
}
}
/* телефон: список и переписка — отдельными экранами */
@media (max-width: 860px) {
.bo-layout--chats .bo-main {
padding: 12px 10px calc(96px + env(safe-area-inset-bottom));
}
.bo-layout--thread .bo-main {
padding-bottom: 10px;
}
.bo-layout--thread .bo-tabbar,
.bo-layout--thread .bo-banner {
display: none;
}
.bo-chat,
.bo-chat--side {
grid-template-columns: minmax(0, 1fr);
}
.bo-chat--thread .bo-chat__list,
.bo-chat:not(.bo-chat--thread) .bo-chat__conv {
display: none;
}
.bo-conv__back {
display: inline-grid;
}
.bo-conv__head {
padding: 10px 10px 10px 12px;
gap: 8px;
}
.bo-conv__head > .bo-avatar {
display: none;
}
.bo-hide-sm {
display: none;
}
.bo-msg {
max-width: 88%;
}
.bo-composer {
padding: 6px 8px 10px;
}
.bo-tpl {
width: min(320px, calc(100vw - 40px));
}
}
+2 -14
View File
@@ -27,18 +27,6 @@ const PATHS = {
arrowUp: 'M12 19V5M6 11l6-6 6 6',
arrowDown: 'M12 5v14M6 13l6 6 6-6',
external: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6',
clip: 'M20 11.5l-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8',
bolt: 'M13 3L5 13.5h6L10 21l8-10.5h-6z',
lock: 'M6 11h12v9H6zM8.5 11V8a3.5 3.5 0 0 1 7 0v3',
file: 'M7 3h7l5 5v13H7zM14 3v5h5',
info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v5M12 7.5v.01',
swap: 'M4 8h14l-3.5-3.5M20 16H6l3.5 3.5',
userPlus: 'M10 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM3 21a7 7 0 0 1 12-4.9M19 14v6M16 17h6',
archive: 'M4 5h16v4H4zM5 9v10h14V9M10 13h4',
bot: 'M6 9h12v10H6zM12 5v4M9.5 13.5v.01M14.5 13.5v.01M9 17h6M3 13v2M21 13v2',
warn: 'M12 3l10 18H2zM12 10v5M12 18v.01',
gear: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
phone: 'M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z',
} as const;
export type IconName = keyof typeof PATHS;
@@ -97,7 +85,7 @@ export function sourceKeyLabel(t: T, key: string) {
}
/** Модальное окно на <dialog>: фокус, Esc и затемнение — силами браузера */
export function Dialog({ open, onClose, title, children, footer, wide }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer: ReactNode; wide?: boolean }) {
export function Dialog({ open, onClose, title, children, footer }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer: ReactNode }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
@@ -108,7 +96,7 @@ export function Dialog({ open, onClose, title, children, footer, wide }: { open:
return (
<dialog
ref={ref}
className={`bo-dialog${wide ? ' bo-dialog--wide' : ''}`}
className="bo-dialog"
onClose={onClose}
onClick={(e) => {
if (e.target === ref.current) onClose();
-96
View File
@@ -1,96 +0,0 @@
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
@@ -1,361 +0,0 @@
/*
* Этап 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;
}
}
File diff suppressed because it is too large. Load diff
+1 -9
View File
@@ -1,19 +1,11 @@
import '@barev/ui/styles.css';
import './styles.css';
import './chats.css';
import './orders.css';
import './crm.css';
import './admin5.css';
import './moves.css';
import { ApiProvider, loadTextOverrides, ToastProvider } from '@barev/web';
import { ApiProvider, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import { BoI18nProvider } from './i18n.tsx';
// названия услуг и другие тексты, изменённые в «Продуктах»
void loadTextOverrides();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ApiProvider withCredentials>
-316
View File
@@ -1,316 +0,0 @@
/* Раздел «Заказы»: вкладки Заказы · Переезды · Заявки */
.bo-subtabs {
display: flex;
gap: 4px;
margin: -4px 0 16px;
border-bottom: 1px solid var(--bc-line);
overflow-x: auto;
scrollbar-width: none;
}
.bo-subtabs a {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px 12px;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--bc-ink-2);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.bo-subtabs a[aria-current='page'] {
color: var(--bc-ink);
border-bottom-color: var(--bc-plum);
}
.bo-subtabs__n {
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--bc-accent);
color: #fff;
font-size: 12px;
display: inline-grid;
place-items: center;
}
/* ───── список переездов ───── */
.bo-mvrows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.bo-mvrow {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 0.8fr) minmax(0, 1.3fr) auto;
align-items: center;
gap: 16px;
padding: 14px 18px;
border-radius: var(--bo-r-row);
background: var(--bo-panel);
color: inherit;
text-decoration: none;
}
.bo-mvrow:hover {
box-shadow: 0 6px 20px rgba(30, 27, 26, 0.06);
}
.bo-mvrow__main,
.bo-mvrow__col {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.bo-mvrow__title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-mvrow__sub {
font-size: 13px;
color: var(--bc-ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-mvrow__status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
}
.bo-mvbar {
display: block;
height: 5px;
border-radius: 5px;
background: var(--bc-line);
overflow: hidden;
}
.bo-mvbar span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--bc-home);
}
.bo-mvbar--big {
height: 8px;
margin: 4px 0 14px;
}
/* ───── карточка: шаги плана ───── */
.bo-mvsteps {
list-style: none;
margin: 0 0 12px;
padding: 0;
}
.bo-mvstep {
position: relative;
display: flex;
gap: 12px;
padding-bottom: 14px;
}
.bo-mvstep::before {
content: '';
position: absolute;
left: 10px;
top: 24px;
bottom: 0;
width: 2px;
background: var(--bc-line);
}
.bo-mvstep:last-child::before {
display: none;
}
.bo-mvstep[data-status='DONE']::before {
background: var(--bc-home);
}
.bo-mvstep__dot {
flex: none;
width: 22px;
height: 22px;
margin-top: 7px;
border-radius: 50%;
border: 2px solid var(--bc-line);
background: var(--bo-panel);
display: grid;
place-items: center;
color: var(--bc-home-ink);
position: relative;
z-index: 1;
}
.bo-mvstep[data-status='DONE'] .bo-mvstep__dot {
background: var(--bc-home);
border-color: var(--bc-home);
}
.bo-mvstep[data-status='IN_PROGRESS'] .bo-mvstep__dot {
border: 6px solid var(--bc-accent);
}
.bo-mvstep[data-status='SKIPPED'] {
opacity: 0.5;
}
.bo-mvstep__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.bo-mvstep__top {
display: flex;
align-items: center;
gap: 8px;
}
.bo-mvstep__top .bo-input {
flex: 1;
}
.bo-mvstep__top .bo-select {
width: auto;
flex: none;
}
.bo-mvstep__name {
flex: 1;
min-width: 0;
text-align: left;
border: 0;
background: none;
padding: 6px 0;
font: inherit;
font-weight: 600;
color: inherit;
cursor: text;
}
.bo-mvstep__name:hover {
text-decoration: underline dotted;
}
.bo-mvstep__note {
display: flex;
gap: 8px;
}
.bo-mvstep__note .bo-input {
min-height: 38px;
font-size: 14px;
}
.bo-mvstep[data-status='DONE'] .bo-mvstep__note .bo-input:placeholder-shown {
display: none;
}
/* документы */
.bo-mvdocs {
list-style: none;
margin: 0 0 12px;
padding: 0;
}
.bo-mvdocs li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-top: 1px solid var(--bc-line);
}
.bo-mvdocs li:first-child {
border-top: 0;
}
.bo-mvdocs label {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.bo-mvdocs input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: var(--bc-plum);
}
.bo-mvdocs li[data-checked] span {
color: var(--bc-ink-3);
text-decoration: line-through;
}
.bo-mvadd {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bo-mvadd .bo-select {
width: auto;
min-width: 220px;
}
.bo-mvadd .bo-input {
flex: 1;
min-width: 200px;
}
.bo-mvwants {
margin: 0;
padding-left: 18px;
}
/* ───── заявки ───── */
.bo-rqgrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bo-rqlist {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.bo-rq {
display: flex;
flex-direction: column;
gap: 5px;
padding: 14px;
border-radius: 18px;
background: var(--bo-soft);
}
.bo-rq__top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.bo-rq__who {
font-size: 14px;
}
.bo-rq__note {
padding: 8px 10px;
border-radius: 12px;
background: var(--bo-panel);
}
.bo-rq__notefield {
display: flex;
gap: 6px;
}
.bo-rq__notefield .bo-input {
min-height: 36px;
font-size: 13px;
background: var(--bo-panel);
}
.bo-rq__actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
@media (max-width: 1100px) {
.bo-mvrow {
grid-template-columns: minmax(0, 1fr) auto;
}
.bo-mvrow__col {
display: none;
}
.bo-rqgrid {
grid-template-columns: minmax(0, 1fr);
}
}
-733
View File
@@ -1,733 +0,0 @@
/*
* Этап 3: заказы и исполнители.
* Список заказов — лента по дням: время слева, услуга и клиент, кто исполняет, статус.
*/
/* ───── общие мелочи ───── */
.bo-chip--ok {
background: var(--bc-home);
color: var(--bc-home-ink);
}
.bo-chip--warn {
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
.bo-chip--danger {
background: var(--bc-pink);
color: var(--bc-danger);
}
.bo-alert {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 12px 18px;
border: 0;
border-radius: var(--bo-r-row);
background: var(--bc-accent);
color: #fff;
font: inherit;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.bo-person {
display: flex;
align-items: center;
gap: 12px;
color: inherit;
text-decoration: none;
}
.bo-person b {
font-weight: 600;
}
.bo-stars {
color: var(--bc-perks-ink);
letter-spacing: 1px;
white-space: nowrap;
}
.bo-stars[data-low] {
color: var(--bc-danger);
}
.bo-kv--plain dt {
color: inherit;
opacity: 0.75;
}
/* переключатель автораспределения */
.bo-autosw {
display: inline-flex;
align-items: center;
gap: 10px;
height: 42px;
padding: 0 16px 0 6px;
border-radius: var(--bc-pill);
border: 1.5px solid var(--bc-line);
background: var(--bo-panel);
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--bc-ink-2);
cursor: pointer;
}
.bo-autosw:disabled {
cursor: default;
}
.bo-autosw__knob {
width: 40px;
height: 26px;
border-radius: var(--bc-pill);
background: var(--bc-line);
position: relative;
transition: background 0.2s;
}
.bo-autosw__knob::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.2s;
}
.bo-autosw.is-on {
color: var(--bc-ok);
}
.bo-autosw.is-on .bo-autosw__knob {
background: var(--bc-ok);
}
.bo-autosw.is-on .bo-autosw__knob::after {
transform: translateX(14px);
}
/* ───── список заказов ───── */
.bo-ogroups {
display: flex;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
margin-bottom: 18px;
border-bottom: 1.5px solid var(--bc-line);
}
.bo-ogroup {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px 12px;
border: 0;
border-bottom: 3px solid transparent;
margin-bottom: -1.5px;
background: none;
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--bc-ink-2);
white-space: nowrap;
cursor: pointer;
}
.bo-ogroup[aria-selected='true'] {
color: var(--bc-ink);
border-bottom-color: var(--bc-accent);
}
.bo-ogroup__dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.bo-ogroup__n {
font-family: var(--bc-font-head);
font-variant-numeric: tabular-nums;
color: var(--bc-ink-3);
}
.bo-oday {
display: flex;
align-items: baseline;
gap: 10px;
margin: 0 4px 8px;
font-family: var(--bc-font-head);
font-size: 15px;
font-weight: 700;
}
.bo-orows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
}
.bo-orow {
display: grid;
grid-template-columns: 64px minmax(0, 1.6fr) minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 12px 18px 12px 14px;
border-radius: var(--bo-r-row);
background: var(--bo-panel);
color: inherit;
text-decoration: none;
box-shadow: inset 4px 0 0 transparent;
}
.bo-orow:hover {
box-shadow: 0 6px 20px rgba(30, 27, 26, 0.06);
}
.bo-orow[data-group='new'] {
box-shadow: inset 4px 0 0 var(--bc-accent);
}
.bo-orow[data-group='cancelled'] {
opacity: 0.6;
}
.bo-orow__time {
display: flex;
flex-direction: column;
font-family: var(--bc-font-head);
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.bo-orow__time b {
font-size: 18px;
}
.bo-orow__time span {
font-size: 12px;
color: var(--bc-ink-3);
}
.bo-orow__main,
.bo-orow__who {
min-width: 0;
}
.bo-orow__title {
display: block;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-orow__sub {
display: block;
font-size: 13px;
color: var(--bc-ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-orow__who {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-orow__pending {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--bc-perks-ink);
}
.bo-orow__status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
font-size: 13px;
}
/* ───── карточка заказа ───── */
.bo-ohead {
display: flex;
gap: 16px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.bo-ohead__kicker {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
font-family: var(--bc-font-head);
font-weight: 700;
color: var(--bc-ink-2);
}
.bo-steps {
list-style: none;
margin: 0 0 22px;
padding: 0;
display: flex;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.bo-steps li {
flex: 1 0 auto;
min-width: 96px;
padding: 10px 12px 9px;
border-radius: 12px;
background: var(--bo-panel);
font-size: 13px;
font-weight: 600;
color: var(--bc-ink-3);
border-top: 4px solid var(--bc-line);
}
.bo-steps li[data-state='done'] {
color: var(--bc-ink-2);
border-top-color: var(--bc-ok);
}
.bo-steps li[data-state='now'] {
color: var(--bc-ink);
border-top-color: var(--bc-accent);
background: #fff;
box-shadow: 0 6px 18px rgba(30, 27, 26, 0.07);
}
.bo-checks {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.bo-checks li {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
}
.bo-checks__box {
width: 22px;
height: 22px;
border-radius: 7px;
border: 1.5px solid var(--bc-line);
display: grid;
place-items: center;
flex: none;
}
.bo-checks li[data-done] .bo-checks__box {
background: var(--bc-ok);
border-color: var(--bc-ok);
color: #fff;
}
.bo-photos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 8px;
margin-top: 14px;
}
.bo-photos img {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 14px;
}
.bo-timeline {
list-style: none;
margin: 0;
padding: 0 0 0 14px;
border-left: 2px solid var(--bc-line);
display: grid;
gap: 12px;
}
.bo-timeline li {
position: relative;
display: flex;
flex-direction: column;
gap: 1px;
font-size: 14px;
}
.bo-timeline li::before {
content: '';
position: absolute;
left: -20px;
top: 5px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--bc-bg);
border: 2px solid var(--bc-ink-3);
}
.bo-timeline li:last-child::before {
border-color: var(--bc-accent);
background: var(--bc-accent);
}
/* подбор исполнителя */
.bo-contact {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.bo-pendingbox {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
.bo-assignbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.bo-cands {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.bo-cands .bo-small {
overflow-wrap: anywhere;
}
.bo-cands li {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 16px;
background: var(--bo-soft);
}
.bo-cands li:first-child {
background: var(--bc-home);
color: var(--bc-home-ink);
}
/* самый выгодный для нас из доступных — обводка, чтобы оператор видел его и среди первых по подбору */
.bo-cands li[data-best] {
outline: 2px solid var(--bc-plum);
outline-offset: -2px;
}
.bo-cands li[data-best] .bo-chip--ok {
background: var(--bc-plum);
color: #fff;
}
.bo-cands__rank {
width: 26px;
height: 26px;
border-radius: 50%;
display: grid;
place-items: center;
background: #fff;
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 13px;
flex: none;
}
/* время: дни и слоты */
.bo-days {
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.bo-day {
flex: none;
width: 54px;
padding: 6px 0;
border-radius: 14px;
border: 1.5px solid var(--bc-line);
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
font: inherit;
font-size: 12px;
color: var(--bc-ink-2);
cursor: pointer;
}
.bo-day b {
font-family: var(--bc-font-head);
font-size: 18px;
color: var(--bc-ink);
}
.bo-day[aria-pressed='true'] {
background: var(--bc-ink);
border-color: var(--bc-ink);
color: rgba(255, 255, 255, 0.75);
}
.bo-day[aria-pressed='true'] b {
color: #fff;
}
.bo-slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
gap: 6px;
}
.bo-slot {
position: relative;
height: 40px;
border-radius: 12px;
border: 1.5px solid var(--bc-line);
background: #fff;
font: inherit;
font-family: var(--bc-font-head);
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.bo-slot span {
position: absolute;
top: 3px;
right: 6px;
font-size: 10px;
color: var(--bc-ink-3);
}
.bo-slot[aria-pressed='true'] {
background: var(--bc-accent);
border-color: var(--bc-accent);
color: #fff;
}
.bo-slot[aria-pressed='true'] span {
color: rgba(255, 255, 255, 0.7);
}
/* новый заказ */
.bo-dialog--wide {
width: min(680px, calc(100vw - 32px));
}
.bo-quote {
align-self: center;
}
.bo-quote b {
font-size: 20px;
}
.bo-stepper {
display: inline-flex;
align-items: center;
gap: 4px;
height: 42px;
padding: 3px;
border-radius: var(--bc-pill);
border: 1.5px solid var(--bc-line);
background: #fff;
align-self: flex-start;
}
.bo-stepper button {
width: 34px;
height: 34px;
border-radius: 50%;
border: 0;
background: var(--bo-soft);
font-size: 18px;
cursor: pointer;
}
.bo-stepper button:disabled {
opacity: 0.35;
cursor: default;
}
.bo-stepper b {
min-width: 26px;
text-align: center;
font-size: 16px;
}
.bo-stepper--sm {
height: 34px;
}
.bo-stepper--sm button {
width: 26px;
height: 26px;
font-size: 15px;
}
.bo-extras {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 6px;
margin-top: 10px;
}
.bo-extra {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 6px 6px 12px;
border-radius: 14px;
background: #fff;
font-size: 14px;
min-height: 46px;
}
.bo-extra[data-on] {
background: var(--bc-pink);
color: var(--bc-plum);
}
.bo-extra input[type='checkbox'] {
width: 20px;
height: 20px;
margin-right: 8px;
accent-color: var(--bc-accent);
}
.bo-picklist {
list-style: none;
margin: 6px 0 0;
padding: 4px;
border-radius: 16px;
background: #fff;
box-shadow: 0 0 0 1px var(--bc-line);
max-height: 260px;
overflow-y: auto;
}
.bo-picklist button {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border: 0;
border-radius: 12px;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.bo-picklist button:hover {
background: var(--bo-soft);
}
/* ───── исполнители ───── */
.bo-ptable tbody tr {
cursor: pointer;
}
.bo-ptable .bo-who {
color: inherit;
text-decoration: none;
align-items: flex-start;
}
.bo-ptable .bo-who__name,
.bo-ptable .bo-who__meta {
display: block;
}
.bo-ptable .bo-who__name {
font-weight: 600;
}
.bo-ptable tbody tr[data-off] td {
opacity: 0.6;
}
.bo-ptable .bo-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.bo-pavatar {
position: relative;
display: inline-flex;
}
.bo-pavatar[data-shift]::after {
content: '';
position: absolute;
right: -1px;
bottom: -1px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--bc-ok);
border: 2px solid #fff;
}
.bo-quality {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.bo-quality > div {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px;
border-radius: 16px;
background: var(--bo-soft);
}
.bo-quality .bo-bignum {
font-size: 26px;
}
.bo-week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 4px;
margin-top: 14px;
}
.bo-week__day {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 2px;
border-radius: 12px;
background: var(--bc-home);
color: var(--bc-home-ink);
font-size: 11px;
text-align: center;
}
.bo-week__day[data-off] {
background: var(--bo-soft);
color: var(--bc-ink-3);
}
.bo-sched {
display: grid;
gap: 6px;
margin-top: 6px;
}
.bo-sched__row {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
}
.bo-sched__row .bo-input {
width: 110px;
min-height: 36px;
padding: 4px 10px;
}
.bo-sched__row[data-off] {
color: var(--bc-ink-3);
}
@media (max-width: 860px) {
.bo-orow {
grid-template-columns: 54px minmax(0, 1fr);
gap: 4px 12px;
}
.bo-orow__who {
grid-column: 2;
}
.bo-orow__status {
grid-column: 2;
flex-direction: row;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
}
.bo-quality {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bo-ptable th:nth-child(4),
.bo-ptable td:nth-child(4),
.bo-ptable th:nth-child(5),
.bo-ptable td:nth-child(5) {
display: none;
}
}
/* чек-лист: команда отмечает за исполнителя */
.bo-checks__toggle {
display: inline-flex;
align-items: center;
gap: 10px;
border: 0;
background: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.bo-checks__toggle:disabled {
cursor: default;
}
.bo-checks__toggle:hover:not(:disabled) .bo-checks__box {
border-color: var(--bc-accent);
}
-408
View File
@@ -1,408 +0,0 @@
/** Этап 5: рассылки, журнал действий, настройки */
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
import { actionLabel } from './Dashboard.tsx';
const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const;
type L5 = Record<(typeof LANGS)[number], string>;
// ───── рассылка ─────
/** Рассылка по текущим фильтрам списка клиентов */
export function BroadcastDialog({ filters, onClose }: { filters: Record<string, unknown>; onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const preview = useQuery(trpc.bo.broadcasts.preview.queryOptions(filters as never));
const [texts, setTexts] = useState<L5>({ ru: '', en: '', hy: '', zh: '', es: '' });
const [more, setMore] = useState(false);
const [appButton, setAppButton] = useState(true);
const [sure, setSure] = useState(false);
const onError = (e: unknown) => toast(errText(t, e), 'error');
const test = useMutation(trpc.bo.broadcasts.test.mutationOptions({ onSuccess: () => toast(t('bc.testSent')), onError }));
const send = useMutation(
trpc.bo.broadcasts.send.mutationOptions({
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: trpc.bo.broadcasts.list.queryKey() });
toast(t('bc.started', { n: r.total }));
onClose();
location.hash = '#/broadcasts';
},
onError,
}),
);
const p = preview.data;
const missing = p ? LANGS.filter((l) => l !== 'ru' && p.byLocale[l] > 0 && !texts[l].trim()) : [];
return (
<Dialog
open
wide
onClose={onClose}
title={t('bc.title')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" disabled={!texts.ru.trim() || test.isPending} onClick={() => test.mutate({ text: texts.ru, appButton })}>
{t('bc.test')}
</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 bo-btn--accent"
disabled={!texts.ru.trim() || !p?.total || send.isPending}
onClick={() => (sure ? send.mutate({ filters: filters as never, texts, appButton }) : setSure(true))}
>
{sure ? t('bc.confirm', { n: p?.total ?? 0 }) : t('bc.send', { n: p?.total ?? 0 })}
</button>
</>
}
>
<div className="bo-bcwho">
<I name="clients" size={20} />
{p ? (
<span>
<b>{t('bc.recipients', { n: p.total })}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{LANGS.filter((l) => p.byLocale[l] > 0)
.map((l) => `${l.toUpperCase()} ${p.byLocale[l]}`)
.join(' · ') || '—'}
</span>
</span>
) : (
<span className="bo-muted">{t('ui.loading')}</span>
)}
</div>
<p className="bo-hint">{t('bc.filtersHint')}</p>
<Field label={t('bc.textRu')}>
<textarea className="bo-textarea" style={{ minHeight: 120 }} value={texts.ru} maxLength={3500} onChange={(e) => (setTexts({ ...texts, ru: e.target.value }), setSure(false))} placeholder={t('bc.textPh')} />
</Field>
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => setMore((m) => !m)}>
{t('bc.otherLangs')} {more ? '▴' : '▾'}
</button>
{more &&
LANGS.filter((l) => l !== 'ru').map((l) => (
<Field key={l} label={`${l.toUpperCase()}${p?.byLocale[l] ? ` · ${t('bc.clientsN', { n: p.byLocale[l] })}` : ''}`}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={texts[l]} maxLength={3500} onChange={(e) => setTexts({ ...texts, [l]: e.target.value })} />
</Field>
))}
{missing.length > 0 && <p className="bo-hint">{t('bc.fallback', { langs: missing.map((l) => l.toUpperCase()).join(', ') })}</p>}
<label className="bo-check">
<input type="checkbox" checked={appButton} onChange={(e) => setAppButton(e.target.checked)} />
{t('bc.appButton')}
</label>
{sure && <p className="bo-hint" style={{ color: 'var(--bc-danger)' }}>{t('bc.sureHint')}</p>}
</Dialog>
);
}
export function Broadcasts() {
const { t, dateTime } = useT();
const trpc = useTRPC();
const toast = useToast();
const qc = useQueryClient();
const list = useQuery({ ...trpc.bo.broadcasts.list.queryOptions(), refetchInterval: (q) => (q.state.data?.some((b) => b.status === 'SENDING') ? 3000 : false) });
const cancel = useMutation(trpc.bo.broadcasts.cancel.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.broadcasts.list.queryKey() }), onError: (e) => toast(errText(t, e), 'error') }));
return (
<>
<a className="bo-back" href="#/clients">
<I name="back" size={16} />
{t('card.back')}
</a>
<div className="bo-head">
<h1 className="bo-title">{t('bc.history')}</h1>
</div>
<p className="bo-hint" style={{ marginBottom: 14 }}>
{t('bc.historyHint')}
</p>
{!list.data ? (
<Skeleton h={200} />
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('bc.none')}</div>
) : (
<div className="bo-col" style={{ gap: 10 }}>
{list.data.map((b) => {
const done = b.sent + b.blocked + b.failed;
return (
<section key={b.id} className="bo-panel">
<div className="bo-panel__head">
<span className="bo-small bo-muted">
{dateTime(b.createdAt)} · {b.by ?? '—'}
</span>
<span className={`bo-chip${b.status === 'DONE' ? ' bo-chip--ok' : b.status === 'SENDING' ? ' bo-chip--warn' : ''}`}>{t(`bc.status.${b.status}`)}</span>
</div>
<p style={{ margin: '0 0 12px', whiteSpace: 'pre-wrap' }}>{b.text}</p>
<div className="bo-bar__track" style={{ height: 8 }}>
<div className="bo-bar__fill" style={{ width: `${b.total ? (done / b.total) * 100 : 0}%` }} />
</div>
<div className="bo-small" style={{ display: 'flex', gap: 14, flexWrap: 'wrap', marginTop: 8 }}>
<span>{t('bc.sent', { n: b.sent, total: b.total })}</span>
{b.blocked > 0 && <span className="bo-muted">{t('bc.blocked', { n: b.blocked })}</span>}
{b.failed > 0 && <span style={{ color: 'var(--bc-danger)' }}>{t('bc.failed', { n: b.failed })}</span>}
{b.status === 'SENDING' && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-danger)', padding: 0 }} onClick={() => confirm(t('bc.cancelConfirm')) && cancel.mutate({ id: b.id })}>
{t('bc.cancel')}
</button>
)}
</div>
</section>
);
})}
</div>
)}
</>
);
}
// ───── журнал ─────
/** Куда ведёт запись журнала */
function entityHref(entity: string, id: string | null) {
if (!id) return null;
const map: Record<string, string> = { clients: 'clients', orders: 'orders', providers: 'providers', chats: 'chats', crm: 'crm', agency: 'agencies', venue: 'venues' };
const base = map[entity];
return base ? `#/${base}/${id}` : null;
}
export function Journal() {
const { t, dateTime } = useT();
const trpc = useTRPC();
const [who, setWho] = useState('');
const [entity, setEntity] = useState('');
const [days, setDays] = useState(7);
const [open, setOpen] = useState<string | null>(null);
const facets = useQuery(trpc.bo.journal.facets.queryOptions());
const list = useInfiniteQuery({
...trpc.bo.journal.list.infiniteQueryOptions({ adminUserId: who || undefined, entity: entity || undefined, days }, { getNextPageParam: (p) => p.next ?? undefined }),
placeholderData: keepPreviousData,
});
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('jr.title')}</h1>
</div>
<div className="bo-toolbar">
<select className="bo-select" style={{ width: 'auto' }} value={who} onChange={(e) => setWho(e.target.value)} aria-label={t('jr.who')}>
<option value="">{t('jr.everyone')}</option>
{facets.data?.staff.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<select className="bo-select" style={{ width: 'auto' }} value={entity} onChange={(e) => setEntity(e.target.value)} aria-label={t('jr.what')}>
<option value="">{t('jr.allSections')}</option>
{facets.data?.entities.map((x) => (
<option key={x} value={x}>
{t(`jr.entity.${x}`) === `jr.entity.${x}` ? x : t(`jr.entity.${x}`)}
</option>
))}
</select>
<div className="bo-pills">
{[1, 7, 30, 90].map((d) => (
<button key={d} type="button" className="bo-pill" aria-pressed={days === d} onClick={() => setDays(d)}>
{t('jr.days', { n: d })}
</button>
))}
</div>
</div>
{list.isPending ? (
<Skeleton h={300} />
) : items.length === 0 ? (
<div className="bo-panel bo-empty">{t('jr.empty')}</div>
) : (
<section className="bo-panel" style={{ padding: 8 }}>
<ul className="bo-journal">
{items.map((r) => {
const href = entityHref(r.entity, r.entityId);
return (
<li key={r.id}>
<span className="bo-feed__time">{dateTime(r.at)}</span>
{r.by ? <Avatar name={r.by.name} id={r.by.id} size={28} /> : <span style={{ width: 28 }} />}
<span className="bo-grow" style={{ minWidth: 0, whiteSpace: 'normal' }}>
<b>{r.by?.name ?? t('jr.system')}</b> {actionLabel(t, r.action)}
{href && (
<>
{' '}
<a href={href} className="bo-small">
{t('jr.open')}
</a>
</>
)}
{r.data && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-ink-3)', padding: '0 0 0 8px', fontSize: 12 }} onClick={() => setOpen(open === r.id ? null : r.id)}>
{open === r.id ? t('jr.hide') : t('jr.details')}
</button>
)}
{open === r.id && <pre className="bo-json">{JSON.stringify(r.data, null, 2)}</pre>}
</span>
</li>
);
})}
</ul>
{list.hasNextPage && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" style={{ margin: 10 }} disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('jr.more')}
</button>
)}
</section>
)}
</>
);
}
// ───── настройки ─────
const GROUPS: { key: string; keys: string[] }[] = [
{ key: 'orders', keys: ['cancel.freeHours', 'offer.acceptTimeoutMinutes', 'assign.auto', 'assign.maxAutoOffers'] },
{ key: 'subs', keys: ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'elite.monthlyPrice', 'elite.maxDiscountPct'] },
// подарок новым клиентам задаётся у каждого агентства; giftDays — только число в текстах лендинга
{ key: 'agencies', keys: ['agency.attributionMonths', 'agency.defaultCommissionPct', 'barevPlus.giftDays'] },
{ key: 'points', keys: ['points.pendingHours', 'points.maxSharePct', 'points.minRedeem', 'points.burnDays', 'points.warnDays1', 'points.warnDays2', 'points.requestTtlHours', 'points.streakGapDays', 'points.diffHighlightPct', 'points.supportCanManage', 'points.opMaxPerOperation', 'points.opMaxPerDay'] },
{ key: 'partners', keys: ['partner.fotPct', 'partner.taxPct', 'partner.minTravelGapMinutes', 'partner.acceptTimeoutMinutes'] },
{ key: 'sales', keys: ['sales.bonusPerVenue', 'sales.bonusPerAgency', 'sales.bonusAgencyPct', 'sales.bonusAgencyMonths'] },
{ key: 'site', keys: ['relocation.hotelWithPlus', 'relocation.hotelBase', 'relocation.consultation', 'docs.fromPrice', 'docs.socialCardPrice', 'residence.escortFrom', 'residence.stateFee', 'transfer.standardPrice', 'transfer.premiumPrice'] },
{ key: 'other', keys: ['chat.closeAfterHours', 'files.retentionDays', 'consent.version'] },
];
export function Settings() {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const s = useQuery(trpc.bo.settings.get.queryOptions());
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
if (s.data && !draft) setDraft({ ...s.data });
}, [s.data, draft]);
const save = useMutation(
trpc.bo.settings.save.mutationOptions({
onSuccess: () => {
qc.invalidateQueries();
toast(t('prv.saved'));
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
if (!draft || !s.data) return <Skeleton h={400} />;
const changed = Object.keys(draft).filter((k) => draft[k] !== (s.data as Record<string, unknown>)[k]);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('st.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" disabled={!changed.length || save.isPending} onClick={() => save.mutate(Object.fromEntries(changed.map((k) => [k, draft[k]])) as never)}>
{changed.length ? t('st.saveN', { n: changed.length }) : t('ui.save')}
</button>
</div>
<div className="bo-settings">
{GROUPS.map((g) => (
<section key={g.key} className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t(`st.group.${g.key}`)}</h2>
</div>
<div className="bo-col" style={{ gap: 12 }}>
{g.keys.map((k) => {
const v = draft[k];
const label = shared(`admin.settings.${k.replace('.', '_')}`);
return typeof v === 'boolean' ? (
<label key={k} className="bo-check">
<input type="checkbox" checked={v} onChange={(e) => setDraft({ ...draft, [k]: e.target.checked })} />
{label}
</label>
) : (
<Field key={k} label={label}>
<input
className="bo-input"
inputMode={typeof v === 'number' ? 'numeric' : undefined}
value={String(v ?? '')}
onChange={(e) => setDraft({ ...draft, [k]: typeof v === 'number' ? Number(e.target.value.replace(/\D/g, '')) || 0 : e.target.value })}
data-changed={changed.includes(k) || undefined}
/>
</Field>
);
})}
</div>
</section>
))}
<PaymentMethods />
</div>
</>
);
}
function PaymentMethods() {
const { t, locale } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.bo.settings.paymentMethods.queryOptions());
const [edit, setEdit] = useState<{ id?: string; code: string; name: L5; active: boolean } | null>(null);
const save = useMutation(
trpc.bo.settings.savePaymentMethod.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.settings.paymentMethods.queryKey() });
setEdit(null);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('st.payments')}</h2>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ code: '', name: { ru: '', en: '', hy: '', zh: '', es: '' }, active: true })}>
<I name="plus" size={16} />
</button>
</div>
<ul className="bo-list">
{(list.data ?? []).map((m) => {
const name = m.name as L5;
return (
<li key={m.id} style={{ opacity: m.active ? 1 : 0.5 }}>
<span className="bo-grow">{name[locale as keyof L5] || name.ru}</span>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ id: m.id, code: m.code, name: { ...{ ru: '', en: '', hy: '', zh: '', es: '' }, ...name }, active: m.active })}>
{t('prv.edit')}
</button>
</li>
);
})}
</ul>
{edit && (
<Dialog
open
onClose={() => setEdit(null)}
title={t('st.payment')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setEdit(null)}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!edit.code.trim() || !edit.name.ru.trim() || save.isPending} onClick={() => save.mutate(edit)}>
{t('ui.save')}
</button>
</>
}
>
<Field label={t('st.code')}>
<input className="bo-input" value={edit.code} maxLength={40} onChange={(e) => setEdit({ ...edit, code: e.target.value.replace(/[^a-z0-9_]/gi, '').toLowerCase() })} placeholder="cash" />
</Field>
{LANGS.map((l) => (
<Field key={l} label={l.toUpperCase()}>
<input className="bo-input" value={edit.name[l]} maxLength={100} onChange={(e) => setEdit({ ...edit, name: { ...edit.name, [l]: e.target.value } })} />
</Field>
))}
<label className="bo-check">
<input type="checkbox" checked={edit.active} onChange={(e) => setEdit({ ...edit, active: e.target.checked })} />
{t('st.paymentActive')}
</label>
</Dialog>
)}
</section>
);
}
-395
View File
@@ -1,395 +0,0 @@
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 };
const GIFT_MONTHS = [1, 2, 3, 6, 12] as const;
type GiftMonths = (typeof GIFT_MONTHS)[number];
const PLAN_NAME = { BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' } as const;
/** «Barev+ на 3 мес.» или «без подарка» */
function giftText(t: ReturnType<typeof useT>['t'], a: { giftPlan: 'BAREV_PLUS' | 'ELITE' | null; giftMonths: number }) {
return a.giftPlan ? t('ag.giftValue', { plan: PLAN_NAME[a.giftPlan], n: a.giftMonths }) : t('ag.noGift');
}
export function Agencies() {
const { t, amd } = useT();
const trpc = useTRPC();
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}%`, giftText(t, a), a.sales?.name].filter(Boolean).join(' · ')}</span>
{!a.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
</span>
</a>
</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>
<span>🎁 {giftText(t, d)}</span>
{d.housewarmingPoints > 0 && <span>🪙 {t('ag.hwValue', { n: new Intl.NumberFormat('ru-RU').format(d.housewarmingPoints), days: d.housewarmingDays })}</span>}
{d.sales && <span>{t('ag.byS', { name: d.sales.name })}</span>}
<span>{t('prv.since', { date: date(d.createdAt) })}</span>
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
</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; giftPlan: 'BAREV_PLUS' | 'ELITE' | null; giftMonths: number; housewarmingPoints: number; housewarmingDays: number; contact: string | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
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),
giftPlan: (agency ? (agency.giftPlan ?? 'NONE') : 'BAREV_PLUS') as 'BAREV_PLUS' | 'ELITE' | 'NONE',
giftMonths: (GIFT_MONTHS.includes(agency?.giftMonths as GiftMonths) ? agency!.giftMonths : 1) as GiftMonths,
hwPoints: String(agency?.housewarmingPoints ?? 0),
hwDays: String(agency?.housewarmingDays ?? 60),
contact: agency?.contact ?? '',
note: agency?.note ?? '',
active: agency?.active ?? true,
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), giftPlan: f.giftPlan === 'NONE' ? null : f.giftPlan, giftMonths: f.giftMonths, housewarmingPoints: Number(f.hwPoints) || 0, housewarmingDays: Number(f.hwDays) || 60, contact: f.contact || null, note: f.note || null, active: f.active, salesId: f.salesId || null })}
>
{t('ui.save')}
</button>
</>
}
>
<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>
<div className="bo-row2">
<Field label={t('ag.giftPlan')} hint={t('ag.giftHint')}>
<select className="bo-select" value={f.giftPlan} onChange={(e) => setF({ ...f, giftPlan: e.target.value as typeof f.giftPlan })}>
<option value="BAREV_PLUS">Barev+</option>
<option value="ELITE">Barev Elite</option>
<option value="NONE">{t('ag.noGift')}</option>
</select>
</Field>
<Field label={t('ag.giftMonths')}>
<select className="bo-select" value={f.giftMonths} disabled={f.giftPlan === 'NONE'} onChange={(e) => setF({ ...f, giftMonths: Number(e.target.value) as GiftMonths })}>
{GIFT_MONTHS.map((m) => (
<option key={m} value={m}>
{t('ag.monthsN', { n: m })}
</option>
))}
</select>
</Field>
</div>
<div className="bo-row2">
<Field label={t('ag.hwPoints')} hint={t('ag.hwHint')}>
<input className="bo-input" inputMode="numeric" value={f.hwPoints} onChange={(e) => setF({ ...f, hwPoints: e.target.value.replace(/\D/g, '').slice(0, 7) })} />
</Field>
<Field label={t('ag.hwDays')}>
<input className="bo-input" inputMode="numeric" value={f.hwDays} disabled={!Number(f.hwPoints)} onChange={(e) => setF({ ...f, hwDays: e.target.value.replace(/\D/g, '').slice(0, 4) })} />
</Field>
</div>
<Field label={t('crm.contacts')}>
<input className="bo-input" value={f.contact} maxLength={200} onChange={(e) => setF({ ...f, contact: e.target.value })} placeholder={t('ag.contactPh')} />
</Field>
<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>
);
}
File diff suppressed because it is too large. Load diff
+13 -39
View File
@@ -1,8 +1,5 @@
import { ClientPoints } from './Points.tsx';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useToast, useTRPC } from '@barev/web';
import { myChannels } from './Chats.tsx';
import { NewOrderDialog } from './NewOrder.tsx';
import { useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT, type BoKey } from '../i18n.tsx';
@@ -18,10 +15,8 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
const trpc = useTRPC();
const c = useQuery(trpc.bo.clients.get.queryOptions({ id }));
const [dlg, setDlg] = useState<{ mode: SubMode; sub?: SubTarget } | null>(null);
const [newOrder, setNewOrder] = useState(false);
const isSuper = me.roles.includes('SUPERADMIN');
const canManage = isSuper || me.roles.includes('SUPPORT');
const canChat = myChannels(me).length > 0;
if (c.isError)
return (
@@ -71,15 +66,12 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
<span>{t('card.since', { date: date(d.createdAt) })}</span>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{canChat && <WriteButton clientId={d.id} />}
{d.username && (
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${d.username}`} target="_blank" rel="noreferrer">
<I name="telegram" size={16} />
{t('card.telegram')}
</a>
)}
</div>
{d.username && (
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${d.username}`} target="_blank" rel="noreferrer">
<I name="telegram" size={16} />
{t('card.telegram')}
</a>
)}
</header>
<div className="bo-client-grid">
@@ -192,12 +184,10 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('card.orders')}</h2>
{canManage && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setNewOrder(true)}>
<I name="plus" size={16} />
{t('card.newOrder')}
</button>
)}
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled title={t('card.newOrderSoon')}>
<I name="plus" size={16} />
{t('card.newOrder')}
</button>
</div>
{d.orders.length === 0 ? (
<p className="bo-hint">{t('card.noOrders')}</p>
@@ -205,9 +195,9 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
<ul className="bo-list">
{d.orders.map((o) => (
<li key={o.id} style={{ flexWrap: 'wrap' }}>
<a href={`#/orders/${o.id}`} className="bo-num bo-faint" style={{ width: 52 }}>
<span className="bo-num bo-faint" style={{ width: 52 }}>
№{o.number}
</a>
</span>
<span className="bo-grow" style={{ minWidth: 140 }}>
<b style={{ fontWeight: 600 }}>{shared(`services.${o.service}.name`)}</b>
<span className="bo-muted bo-small" style={{ display: 'block' }}>
@@ -235,7 +225,6 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
</div>
<div className="bo-col">
{isSuper && <ClientPoints id={d.id} compact />}
{d.money && (
<section className="bo-panel bo-money-card">
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
@@ -330,25 +319,10 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
</div>
{dlg && <SubDialog clientId={d.id} mode={dlg.mode} sub={dlg.sub} current={current} onClose={() => setDlg(null)} />}
{newOrder && <NewOrderDialog clientId={d.id} onClose={() => setNewOrder(false)} />}
</>
);
}
/** Открыть переписку с клиентом (или начать её) — в канале, куда он сейчас пишет */
export function WriteButton({ clientId, small }: { clientId: string; small?: boolean }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const open = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError: (e) => toast(errText(t, e), 'error') }));
return (
<button type="button" className={`bo-btn bo-btn--accent${small ? ' bo-btn--sm' : ''}`} onClick={() => open.mutate({ clientId })} disabled={open.isPending}>
<I name="chats" size={small ? 14 : 16} />
{t('chat.write')}
</button>
);
}
function TagsAndNote({ id, tags, note }: { id: string; tags: string[]; note: string }) {
const { t } = useT();
const trpc = useTRPC();
+4 -15
View File
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, download, Field, I, PlanBadge, Skeleton, sourceLabel } from '../components/kit.tsx';
import { BroadcastDialog } from './Admin5.tsx';
type Plan = 'any' | 'none' | 'BAREV_PLUS' | 'ELITE';
interface Filters {
@@ -79,8 +78,6 @@ export function Clients({ me }: { me: Me }) {
}),
);
const [saveOpen, setSaveOpen] = useState(false);
const [broadcast, setBroadcast] = useState(false);
const canBroadcast = isSuper || me.roles.includes('SUPPORT');
const data = list.data;
const from = data ? page * data.pageSize + 1 : 0;
@@ -93,17 +90,10 @@ export function Clients({ me }: { me: Me }) {
<h1 className="bo-title">{t('clients.title')}</h1>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{canBroadcast && (
<>
<a className="bo-btn bo-btn--ghost" href="#/broadcasts">
{t('bc.historyBtn')}
</a>
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setBroadcast(true)}>
<I name="send" size={16} />
{t('clients.broadcast')}
</button>
</>
)}
<button type="button" className="bo-btn bo-btn--ghost" title={t('clients.broadcastSoon')} disabled>
<I name="send" size={16} />
{t('clients.broadcast')}
</button>
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => exportCsv.mutate(f)} disabled={exportCsv.isPending}>
<I name="download" size={16} />
{t('clients.export')}
@@ -349,7 +339,6 @@ export function Clients({ me }: { me: Me }) {
)}
<SaveSegment open={saveOpen} onClose={() => setSaveOpen(false)} filters={f} />
{broadcast && <BroadcastDialog filters={f as Record<string, unknown>} onClose={() => setBroadcast(false)} />}
</>
);
}
-791
View File
@@ -1,791 +0,0 @@
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>
);
}
+6 -6
View File
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@barev/web';
import type { Me } from '../App.tsx';
import { useT, type BoKey } from '../i18n.tsx';
import { I, PlanBadge, Skeleton, sourceKeyLabel } from '../components/kit.tsx';
import { I, OLD_ADMIN_URL, PlanBadge, Skeleton, sourceKeyLabel } from '../components/kit.tsx';
type Pair = { orders: number; subs: number };
const total = (p: Pair) => p.orders + p.subs;
@@ -55,12 +55,12 @@ export function Dashboard({ me }: { me: Me }) {
type Att = { unassigned: number; unreadChats: number; webRequests: number; leads: number; appRequests: number; lowRatings: number; expiring: number };
const ATT: { key: keyof Att; href: string; urgent?: boolean; fill: string; ink: string }[] = [
{ key: 'unassigned', href: '#/orders?group=new', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'unassigned', href: '#/orders', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'unreadChats', href: '#/chats', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'webRequests', href: '#/requests', fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
{ key: 'leads', href: '#/moves', fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
{ key: 'appRequests', href: '#/requests', fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
{ key: 'lowRatings', href: '#/providers', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
{ key: 'webRequests', href: `${OLD_ADMIN_URL}#/requests`, fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
{ key: 'leads', href: `${OLD_ADMIN_URL}#/leads`, fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
{ key: 'appRequests', href: `${OLD_ADMIN_URL}#/app-requests`, fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
{ key: 'lowRatings', href: '#/orders', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
{ key: 'expiring', href: '#/clients?expiring=7', fill: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' },
];
-354
View File
@@ -1,354 +0,0 @@
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>
);
}
-665
View File
@@ -1,665 +0,0 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatAmd, type Locale } from '@barev/i18n';
import { navigate, paramsSummary, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { I, Skeleton } from '../components/kit.tsx';
import { WriteButton } from './ClientCard.tsx';
import { NewOrderDialog } from './NewOrder.tsx';
/* ───────────── вкладки раздела «Заказы» ───────────── */
/** Заказы · Переезды · Заявки — один раздел; поддержка видит всё, консьерж — переезды и заявки */
export function OrdersTabs({ me, current }: { me: Me; current: 'orders' | 'moves' | 'requests' }) {
const { t } = useT();
const trpc = useTRPC();
const canOrders = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT');
const moves = useQuery({ ...trpc.bo.moves.counts.queryOptions(), refetchInterval: 30_000 });
const reqs = useQuery({ ...trpc.bo.requests.counts.queryOptions(), refetchInterval: 30_000 });
const tabs = [
...(canOrders ? [{ id: 'orders' as const, href: '#/orders', n: 0 }] : []),
{ id: 'moves' as const, href: '#/moves', n: moves.data?.NEW ?? 0 },
{ id: 'requests' as const, href: '#/requests', n: (reqs.data?.site ?? 0) + (reqs.data?.app ?? 0) },
];
return (
<nav className="bo-subtabs" aria-label={t('nav.orders')}>
{tabs.map((x) => (
<a key={x.id} href={x.href} aria-current={current === x.id ? 'page' : undefined}>
{t(`ord.tab.${x.id}`)}
{x.n > 0 && <span className="bo-subtabs__n">{x.n}</span>}
</a>
))}
</nav>
);
}
/* ───────────── список переездов ───────────── */
const STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
type Status = (typeof STATUSES)[number];
export const MOVE_DOT: Record<Status, string> = { NEW: 'var(--bc-accent)', IN_WORK: 'var(--bc-blue-ink)', DONE: 'var(--bc-ok)', CANCELLED: 'var(--bc-ink-3)' };
function whenText(shared: (k: string) => string, date: (d: Date) => string, m: { moveWhen: string | null; moveDate: Date | null }) {
if (m.moveWhen === 'date' && m.moveDate) return date(m.moveDate);
return m.moveWhen ? shared(`landing.quiz.when_${m.moveWhen}`) : '—';
}
export function Moves({ me }: { me: Me }) {
const { t, shared, date, locale } = useT();
const trpc = useTRPC();
const { query } = useLocation();
const status = (query.get('status') as Status | 'all' | null) ?? 'NEW';
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const counts = useQuery({ ...trpc.bo.moves.counts.queryOptions(), refetchInterval: 30_000 });
const list = useQuery({ ...trpc.bo.moves.list.queryOptions({ status: status === 'all' ? undefined : status, search: q || undefined }), refetchInterval: 30_000, placeholderData: keepPreviousData });
const names = useQuery(trpc.bo.moves.names.queryOptions());
const stepName = (s: { code: string; title: string | null }) => s.title || names.data?.[`STEP:${s.code}`]?.name[locale] || names.data?.[`STEP:${s.code}`]?.name.ru || s.code;
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('ord.tab.moves')}</h1>
</div>
<OrdersTabs me={me} current="moves" />
<div className="bo-toolbar">
<label className="bo-search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} placeholder={t('mv.search')} aria-label={t('mv.search')} onChange={(e) => setSearch(e.target.value)} />
</label>
</div>
<div className="bo-ogroups" role="tablist">
{[...STATUSES, 'all' as const].map((s) => (
<button key={s} type="button" role="tab" aria-selected={status === s} className="bo-ogroup" onClick={() => navigate(s === 'NEW' ? '/moves' : `/moves?status=${s}`, { replace: true })}>
{s !== 'all' && <span className="bo-ogroup__dot" style={{ background: MOVE_DOT[s] }} />}
{s === 'all' ? t('mv.all') : shared(`admin.leads.status_${s}`)}
{s !== 'all' && counts.data && <span className="bo-ogroup__n">{counts.data[s]}</span>}
</button>
))}
</div>
{list.isPending ? (
<div className="bo-col">
<Skeleton h={72} />
<Skeleton h={72} />
</div>
) : !list.data?.length ? (
<div className="bo-panel bo-empty">{t('mv.empty')}</div>
) : (
<ul className="bo-mvrows">
{list.data.map((m) => (
<li key={m.id}>
<a href={`#/moves/${m.id}`} className="bo-mvrow">
<span className="bo-mvrow__main">
<span className="bo-mvrow__title">
<span className="bo-num bo-faint">№{m.number}</span> {m.name}
{m.inApp && <span className="bo-chip bo-chip--ok">{t('mv.inApp')}</span>}
</span>
<span className="bo-mvrow__sub">
{[m.contact, m.agency, m.locale.toUpperCase()].filter(Boolean).join(' · ')}
</span>
</span>
<span className="bo-mvrow__col">
<span className="bo-faint bo-small">{t('mv.when')}</span>
<span>{whenText(shared, date, m)}</span>
</span>
<span className="bo-mvrow__col">
<span className="bo-faint bo-small">{m.package ? shared(`landing.quiz.package_${m.package}`) : t('mv.unfinished')}</span>
{m.stepsTotal > 0 ? (
<>
<span className="bo-mvbar" aria-hidden="true">
<span style={{ width: `${(m.stepsDone / m.stepsTotal) * 100}%` }} />
</span>
<span className="bo-small">
{t('mv.steps', { done: m.stepsDone, total: m.stepsTotal })}
{m.current ? ` · ${t('mv.now', { step: stepName(m.current) })}` : ''}
</span>
</>
) : (
<span className="bo-faint">{t('mv.noPlan')}</span>
)}
</span>
<span className="bo-mvrow__status">
<span className="bo-status" style={{ ['--dot' as string]: MOVE_DOT[m.status] }}>
{shared(`admin.leads.status_${m.status}`)}
</span>
{m.docsTotal > 0 && <span className="bo-small bo-faint">{t('mv.docs', { done: m.docsDone, total: m.docsTotal })}</span>}
<span className="bo-small bo-faint">{date(m.createdAt)}</span>
</span>
</a>
</li>
))}
</ul>
)}
</>
);
}
/* ───────────── карточка переезда ───────────── */
const STEP_STATUSES = ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED'] as const;
type StepStatus = (typeof STEP_STATUSES)[number];
const YES_NO = ['flat', 'socialCard', 'bankCard', 'registration', 'residence'] as const;
interface Options {
airport?: 'none' | 'standard' | 'premium';
stay?: 'none' | 'flat' | 'hotel';
stayBudgetFrom?: number;
stayBudgetTo?: number;
flat?: boolean;
socialCard?: boolean;
bankCard?: boolean;
registration?: boolean;
residence?: boolean;
}
export function MoveCard({ id, me }: { id: string; me: Me }) {
const { t, shared, date, dateTime, locale } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const lead = useQuery(trpc.bo.moves.get.queryOptions({ id }));
const names = useQuery(trpc.bo.moves.names.queryOptions());
const templates = useQuery(trpc.bo.moves.templates.queryOptions());
const refresh = () => {
void qc.invalidateQueries({ queryKey: trpc.bo.moves.pathKey() });
};
const opts = { onSuccess: () => (refresh(), toast(t('prv.saved'))), onError: (e: unknown) => toast(errText(t, e), 'error') };
const update = useMutation(trpc.bo.moves.update.mutationOptions(opts));
const addStep = useMutation(trpc.bo.moves.addStep.mutationOptions(opts));
const addDoc = useMutation(trpc.bo.moves.addDoc.mutationOptions(opts));
const updateDoc = useMutation(trpc.bo.moves.updateDoc.mutationOptions(opts));
const removeDoc = useMutation(trpc.bo.moves.removeDoc.mutationOptions(opts));
const [note, setNote] = useState<string | null>(null);
const canChat = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT') || me.roles.includes('CONCIERGE');
const back = (
<a className="bo-back" href="#/moves">
<I name="back" size={16} />
{t('mv.back')}
</a>
);
if (lead.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, lead.error)}</div>
</>
);
if (!lead.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={300} />
</div>
);
const d = lead.data;
const nameOf = (kind: 'STEP' | 'DOC', it: { code: string; title: string | null }) => {
const n = names.data?.[`${kind}:${it.code}`]?.name;
return it.title || n?.[locale] || n?.ru || it.code;
};
const free = (kind: 'STEP' | 'DOC', taken: { code: string }[]) =>
(templates.data ?? [])
.filter((x) => x.kind === kind && x.active && !taken.some((y) => y.code === x.code))
.map((x) => ({ code: x.code, name: (x.name as Record<string, string>)[locale] || (x.name as Record<string, string>).ru || x.code }));
const o = (d.options ?? {}) as Options;
const wants: string[] = [];
if (d.package === 'CUSTOM') {
if (o.airport && o.airport !== 'none') wants.push(`${shared('landing.quiz.airport')}: ${shared(`landing.quiz.airport_${o.airport}`)}`);
if (o.stay && o.stay !== 'none') {
const budget = o.stayBudgetFrom || o.stayBudgetTo ? ` (${shared('admin.leads.budget', { from: o.stayBudgetFrom ? formatAmd(locale as Locale, o.stayBudgetFrom) : '…', to: o.stayBudgetTo ? formatAmd(locale as Locale, o.stayBudgetTo) : '…' })})` : '';
wants.push(`${shared('landing.quiz.stay')}: ${shared(`landing.quiz.stay_${o.stay}`)}${budget}`);
}
for (const k of YES_NO) if (o[k]) wants.push(shared(`landing.quiz.${k}`));
}
const tel = d.contactType === 'PHONE' ? d.contact.replace(/[^\d+]/g, '') : null;
const tg = d.contactType === 'TELEGRAM' ? d.contact.replace(/^@/, '') : null;
const steps = d.steps.filter((s) => s.status !== 'SKIPPED');
const done = steps.filter((s) => s.status === 'DONE').length;
const noteValue = note ?? d.adminNote ?? '';
return (
<>
{back}
<header className="bo-ohead">
<div>
<div className="bo-ohead__kicker">
№{d.number} · {t('mv.created')} {dateTime(d.createdAt)}
</div>
<h1 className="bo-title">{d.name}</h1>
</div>
<div className="bo-pills" role="group" aria-label={t('mv.status')}>
{STATUSES.map((s) => (
<button key={s} type="button" className="bo-pill" aria-pressed={d.status === s} onClick={() => d.status !== s && update.mutate({ id, status: s })}>
{shared(`admin.leads.status_${s}`)}
</button>
))}
</div>
</header>
<div className="bo-client-grid">
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('mv.plan')}</h2>
{steps.length > 0 && <span className="bo-faint">{t('mv.steps', { done, total: steps.length })}</span>}
</div>
<p className="bo-hint">{d.client ? t('mv.planHintApp') : t('mv.planHintNoApp')}</p>
{steps.length > 0 && (
<div className="bo-mvbar bo-mvbar--big" aria-hidden="true">
<span style={{ width: `${(done / steps.length) * 100}%` }} />
</div>
)}
<ol className="bo-mvsteps">
{d.steps.map((s) => (
<StepRow key={s.id} step={s} name={nameOf('STEP', s)} onSaved={refresh} />
))}
</ol>
<AddItem placeholder={t('mv.addStep')} options={free('STEP', d.steps)} onAdd={(v) => addStep.mutate({ id, ...v })} />
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('mv.docsTitle')}</h2>
<span className="bo-faint">
{d.docs.filter((x) => x.checked).length}/{d.docs.length}
</span>
</div>
<p className="bo-hint">{t('mv.docsHint')}</p>
<ul className="bo-mvdocs">
{d.docs.map((x) => (
<li key={x.id} data-checked={x.checked || undefined}>
<label>
<input type="checkbox" checked={x.checked} onChange={(e) => updateDoc.mutate({ id: x.id, checked: e.target.checked })} />
<span>{nameOf('DOC', x)}</span>
</label>
<button type="button" className="bo-iconbtn" title={t('mv.remove')} aria-label={t('mv.remove')} onClick={() => confirm(t('mv.removeConfirm', { name: nameOf('DOC', x) })) && removeDoc.mutate({ id: x.id })}>
<I name="close" size={14} />
</button>
</li>
))}
</ul>
<AddItem placeholder={t('mv.addDoc')} options={free('DOC', d.docs)} onAdd={(v) => addDoc.mutate({ id, ...v })} />
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('mv.listHint')}
</p>
</section>
</div>
<div className="bo-col">
<section className="bo-panel">
<h2 className="bo-h">{t('mv.contact')}</h2>
<div className="bo-contact" style={{ marginTop: 8 }}>
<b>{d.contact}</b>
<div className="bo-faint bo-small">
{t('mv.lang')}: {d.locale.toUpperCase()}
{d.agency ? ` · ${t('mv.agency')}: ${d.agency}` : ''}
</div>
</div>
<div className="bo-row" style={{ flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
{d.client && canChat && <WriteButton clientId={d.client.id} />}
{tg && (
<a className="bo-btn bo-btn--soft" href={`https://t.me/${tg}`} target="_blank" rel="noopener">
<I name="telegram" size={16} />
Telegram
</a>
)}
{tel && (
<a className="bo-btn bo-btn--soft" href={`tel:${tel}`}>
<I name="phone" size={16} />
{t('mv.call')}
</a>
)}
{d.client && (
<a className="bo-btn bo-btn--ghost" href={`#/clients/${d.client.id}`}>
{t('mv.openClient')}
</a>
)}
</div>
</section>
<section className="bo-panel">
<h2 className="bo-h">{t('mv.answers')}</h2>
<dl className="bo-kv" style={{ marginTop: 8 }}>
<dt>{t('mv.when')}</dt>
<dd>{whenText(shared, date, d)}</dd>
<dt>{t('mv.package')}</dt>
<dd>{d.package ? shared(`landing.quiz.package_${d.package}`) : <span className="bo-faint">{t('mv.unfinished')}</span>}</dd>
{wants.length > 0 && (
<>
<dt>{t('mv.wants')}</dt>
<dd>
<ul className="bo-mvwants">
{wants.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
</dd>
</>
)}
</dl>
</section>
<section className="bo-panel">
<h2 className="bo-h">{t('mv.note')}</h2>
<textarea className="bo-textarea" style={{ marginTop: 8 }} rows={4} maxLength={1000} placeholder={t('mv.notePh')} value={noteValue} onChange={(e) => setNote(e.target.value)} />
{note !== null && note !== (d.adminNote ?? '') && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" style={{ marginTop: 8 }} onClick={() => update.mutate({ id, adminNote: note }, { onSuccess: () => setNote(null) })}>
{t('mv.save')}
</button>
)}
</section>
</div>
</div>
</>
);
}
/** Шаг плана: статус, название, комментарий для клиента */
function StepRow({ step, name, onSaved }: { step: { id: string; status: StepStatus; note: string | null }; name: string; onSaved: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const toast = useToast();
const [note, setNote] = useState(step.note ?? '');
const [title, setTitle] = useState<string | null>(null);
useEffect(() => setNote(step.note ?? ''), [step.note]);
const opts = { onSuccess: () => (onSaved(), toast(t('prv.saved'))), onError: (e: unknown) => toast(errText(t, e), 'error') };
const save = useMutation(trpc.bo.moves.updateStep.mutationOptions(opts));
const remove = useMutation(trpc.bo.moves.removeStep.mutationOptions(opts));
return (
<li className="bo-mvstep" data-status={step.status}>
<span className="bo-mvstep__dot" aria-hidden="true">
{step.status === 'DONE' && <I name="check" size={12} stroke={3} />}
</span>
<div className="bo-mvstep__body">
<div className="bo-mvstep__top">
{title === null ? (
<button type="button" className="bo-mvstep__name" title={t('mv.rename')} onClick={() => setTitle(name)}>
{name}
</button>
) : (
<input
className="bo-input"
autoFocus
value={title}
maxLength={120}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => {
if (title.trim() && title.trim() !== name) save.mutate({ id: step.id, title: title.trim() });
setTitle(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') setTitle(null);
}}
/>
)}
<select className="bo-select" value={step.status} aria-label={name} onChange={(e) => save.mutate({ id: step.id, status: e.target.value as StepStatus })}>
{STEP_STATUSES.map((s) => (
<option key={s} value={s}>
{shared(`client.move.status.${s}`)}
</option>
))}
</select>
<button type="button" className="bo-iconbtn" title={t('mv.remove')} aria-label={t('mv.remove')} onClick={() => confirm(t('mv.removeConfirm', { name })) && remove.mutate({ id: step.id })}>
<I name="close" size={14} />
</button>
</div>
<div className="bo-mvstep__note">
<input className="bo-input" value={note} maxLength={500} placeholder={t('mv.stepNote')} onChange={(e) => setNote(e.target.value)} />
{note !== (step.note ?? '') && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => save.mutate({ id: step.id, note })}>
{t('mv.save')}
</button>
)}
</div>
</div>
</li>
);
}
/** Добавить пункт: из общего списка или свой */
function AddItem({ placeholder, options, onAdd }: { placeholder: string; options: { code: string; name: string }[]; onAdd: (v: { code?: string; title?: string }) => void }) {
const { t } = useT();
const [value, setValue] = useState('');
const [title, setTitle] = useState('');
return (
<div className="bo-mvadd">
<select className="bo-select" value={value} onChange={(e) => setValue(e.target.value)} aria-label={placeholder}>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o.code} value={o.code}>
{o.name}
</option>
))}
<option value="__custom">{t('mv.custom')}</option>
</select>
{value === '__custom' && <input className="bo-input" value={title} maxLength={120} placeholder={t('mv.customPh')} onChange={(e) => setTitle(e.target.value)} />}
{value && (
<button
type="button"
className="bo-btn bo-btn--sm bo-btn--accent"
disabled={value === '__custom' && !title.trim()}
onClick={() => {
onAdd(value === '__custom' ? { title: title.trim() } : { code: value });
setValue('');
setTitle('');
}}
>
<I name="plus" size={14} />
{t('mv.add')}
</button>
)}
</div>
);
}
/* ───────────── заявки ───────────── */
export function Requests({ me }: { me: Me }) {
const { t, shared, sharedT, dateTime, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const { query } = useLocation();
const open = query.get('closed') !== '1';
const site = useQuery({ ...trpc.bo.requests.site.queryOptions({ open }), refetchInterval: 30_000 });
const app = useQuery({ ...trpc.bo.requests.app.queryOptions({ open }), refetchInterval: 30_000 });
const [orderFor, setOrderFor] = useState<string | null>(null);
const opts = (msg = t('prv.saved')) => ({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.bo.requests.pathKey() });
toast(msg);
},
onError: (e: unknown) => toast(errText(t, e), 'error'),
});
const updateSite = useMutation(trpc.bo.requests.updateSite.mutationOptions(opts()));
const updateApp = useMutation(trpc.bo.requests.updateApp.mutationOptions(opts()));
const activate = useMutation(trpc.bo.requests.activate.mutationOptions(opts(t('rq.activated'))));
const planName = (p: string | null) => (p === 'ELITE' ? 'Barev Elite' : 'Barev+');
const person = (u: { firstName: string | null; lastName: string | null; username: string | null } | undefined) => [u?.firstName, u?.lastName].filter(Boolean).join(' ') || (u?.username ? `@${u.username}` : '—');
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('ord.tab.requests')}</h1>
<div className="bo-pills" role="group">
<button type="button" className="bo-pill" aria-pressed={open} onClick={() => navigate('/requests', { replace: true })}>
{t('rq.open')}
</button>
<button type="button" className="bo-pill" aria-pressed={!open} onClick={() => navigate('/requests?closed=1', { replace: true })}>
{t('rq.closed')}
</button>
</div>
</div>
<OrdersTabs me={me} current="requests" />
<div className="bo-client-grid bo-rqgrid">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('rq.site')}</h2>
<span className="bo-faint">{site.data?.length ?? ''}</span>
</div>
<p className="bo-hint">{t('rq.siteHint')}</p>
{site.isPending ? (
<Skeleton h={80} />
) : !site.data?.length ? (
<div className="bo-empty">{open ? t('rq.empty') : t('rq.emptyClosed')}</div>
) : (
<ul className="bo-rqlist">
{site.data.map((r) => (
<li key={r.id} className="bo-rq">
<div className="bo-rq__top">
<b>
<span className="bo-num bo-faint">№{r.number}</span> {shared(`services.${r.service.code}.name`)}
</b>
<span className="bo-tags">
{r.status !== 'ORDERED' && r.status !== 'CANCELLED' && (!r.preferredAt || !r.district || !r.addressText) && <span className="bo-chip bo-chip--warn">{t('rq.missing')}</span>}
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
</span>
</div>
<div className="bo-small">{paramsSummary(sharedT, r.service.code, r.params as Record<string, unknown>)}</div>
<div className="bo-small">
{t('rq.wish')}: <b>{r.preferredAt ? dateTime(r.preferredAt) : t('rq.noTime')}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
</div>
<div className="bo-small bo-faint">
{[r.district ? shared(`districts.${r.district.code}`) : t('rq.noDistrict'), r.addressText ?? t('rq.noAddress')].join(', ')}
</div>
<div className="bo-rq__who">
<span>
{r.name || person(r.client?.user)} · <a href={`tel:${r.phone.replace(/[^\d+]/g, '')}`}>{r.phone}</a>
{!r.client && <span className="bo-faint"> · {t('rq.noApp')}</span>}
</span>
</div>
{r.note && <div className="bo-small bo-rq__note">{r.note}</div>}
<NoteLine value={r.adminNote} onSave={(adminNote) => updateSite.mutate({ id: r.id, adminNote })} />
<div className="bo-rq__actions">
{r.order ? (
<a className="bo-btn bo-btn--sm bo-btn--soft" href={`#/orders/${r.order.id}`}>
{t('rq.order', { n: r.order.number })}
</a>
) : (
r.status !== 'CANCELLED' &&
r.client && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" onClick={() => setOrderFor(r.client!.id)}>
<I name="plus" size={14} />
{t('rq.newOrder')}
</button>
)
)}
{r.client && (
<a className="bo-btn bo-btn--sm bo-btn--ghost" href={`#/clients/${r.client.id}`}>
{t('mv.openClient')}
</a>
)}
{r.status === 'CANCELLED' ? (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateSite.mutate({ id: r.id, status: 'REOPEN' })}>
{t('rq.reopen')}
</button>
) : (
r.status !== 'ORDERED' && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => confirm(shared('admin.webRequests.cancelConfirm', { number: r.number })) && updateSite.mutate({ id: r.id, status: 'CANCELLED' })}>
{t('rq.cancel')}
</button>
)
)}
</div>
<div className="bo-faint bo-small">{dateTime(r.createdAt)}</div>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('rq.app')}</h2>
<span className="bo-faint">{app.data?.length ?? ''}</span>
</div>
<p className="bo-hint">{t('rq.appHint')}</p>
{app.isPending ? (
<Skeleton h={80} />
) : !app.data?.length ? (
<div className="bo-empty">{open ? t('rq.empty') : t('rq.emptyClosed')}</div>
) : (
<ul className="bo-rqlist">
{app.data.map((r) => (
<li key={r.id} className="bo-rq">
<div className="bo-rq__top">
<b>
<span className="bo-num bo-faint">№{r.number}</span>{' '}
{r.kind === 'SUBSCRIPTION' ? shared('admin.requests.subscription', { plan: planName(r.plan) }) : t('rq.callback', { service: r.serviceCode ? shared(`services.${r.serviceCode}.name`) : '—' })}
</b>
<span className="bo-chip">{shared(`admin.requests.status_${r.status}`)}</span>
</div>
<div className="bo-rq__who">
<a href={`#/clients/${r.clientId}`}>{person(r.client.user)}</a>
{r.client.user.phone && (
<>
{' · '}
<a href={`tel:${r.client.user.phone}`}>{r.client.user.phone}</a>
</>
)}
<span className="bo-faint"> · {r.client.user.locale.toUpperCase()}</span>
</div>
{r.comment && <div className="bo-small bo-rq__note">{r.comment}</div>}
<NoteLine value={r.adminNote} onSave={(adminNote) => updateApp.mutate({ id: r.id, adminNote })} />
<div className="bo-rq__actions">
{r.status === 'NEW' && r.kind === 'SUBSCRIPTION' && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" onClick={() => confirm(shared('admin.requests.activateConfirm', { plan: planName(r.plan) })) && activate.mutate({ id: r.id, days: 30 })}>
{t('rq.activate')}
</button>
)}
{r.status === 'NEW' && (
<WriteButton clientId={r.clientId} small />
)}
{r.status === 'NEW' ? (
<>
<button type="button" className="bo-btn bo-btn--sm bo-btn--soft" onClick={() => updateApp.mutate({ id: r.id, status: 'DONE' })}>
<I name="check" size={14} />
{t('rq.done')}
</button>
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateApp.mutate({ id: r.id, status: 'CANCELLED' })}>
{t('rq.cancel')}
</button>
</>
) : (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateApp.mutate({ id: r.id, status: 'NEW' })}>
{t('rq.reopen')}
</button>
)}
</div>
<div className="bo-faint bo-small">{dateTime(r.createdAt)}</div>
</li>
))}
</ul>
)}
</section>
</div>
{orderFor && <NewOrderDialog clientId={orderFor} onClose={() => setOrderFor(null)} />}
</>
);
}
/** Заметка команды одной строкой: сохраняется по кнопке */
function NoteLine({ value, onSave }: { value: string | null; onSave: (v: string) => void }) {
const { t } = useT();
const [v, setV] = useState(value ?? '');
useEffect(() => setV(value ?? ''), [value]);
return (
<div className="bo-rq__notefield">
<input className="bo-input" value={v} maxLength={1000} placeholder={t('mv.notePh')} aria-label={t('mv.note')} onChange={(e) => setV(e.target.value)} />
{v !== (value ?? '') && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => onSave(v)}>
{t('mv.save')}
</button>
)}
</div>
);
}
-404
View File
@@ -1,404 +0,0 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDateTime, type Locale } from '@barev/i18n';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useEffect, useMemo, useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge } from '../components/kit.tsx';
export type ParamField =
| { key: string; type: 'select'; options: string[]; default?: string }
| { key: string; type: 'int'; min: number; max: number; default?: number }
| { key: string; type: 'text'; maxLength: number; required?: boolean }
| { key: string; type: 'bool'; default?: boolean }
| { key: string; type: 'extras'; items: { code: string; max: number }[] };
type Params = Record<string, unknown>;
/** Дата по Еревану в виде YYYY-MM-DD */
const dayKey = (d: Date) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 10);
/** Время по Еревану из даты и «чч:мм» */
const atYerevan = (key: string, hhmm: string) => new Date(`${key}T${hhmm}:00+04:00`);
function defaults(fields: ParamField[]): Params {
const out: Params = {};
for (const f of fields) {
if (f.type === 'select') out[f.key] = f.default ?? f.options[0];
else if (f.type === 'int') out[f.key] = f.default ?? f.min;
else if (f.type === 'bool') out[f.key] = f.default ?? false;
else if (f.type === 'extras') out[f.key] = {};
}
return out;
}
/**
* Время заказа: свободные слоты на выбранный день (с учётом графиков и уже назначенных заказов)
* или «другое время» — оператор договорился сам.
*/
export function SlotPicker({ service, params, excludeOrderId, initial, onChange }: { service: string; params: Params; excludeOrderId?: string; initial?: Date; onChange: (v: { when: Date; busy: boolean } | null) => void }) {
const { t, time, locale } = useT();
const trpc = useTRPC();
const [day, setDay] = useState(dayKey(initial && initial > new Date() ? initial : new Date()));
const [picked, setPicked] = useState<Date | null>(null);
const [custom, setCustom] = useState(false);
const [hhmm, setHhmm] = useState('10:00');
const slots = useQuery({ ...trpc.bo.orders.slots.queryOptions({ service, params, dateKey: day, excludeOrderId }), placeholderData: keepPreviousData });
useEffect(() => {
if (custom) onChange(hhmm ? { when: atYerevan(day, hhmm), busy: true } : null);
else onChange(picked ? { when: picked, busy: false } : null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [custom, hhmm, day, picked]);
const days = useMemo(() => {
const out: string[] = [];
for (let i = 0; i < 14; i++) out.push(dayKey(new Date(Date.now() + i * 86_400_000)));
return out;
}, []);
const free = (slots.data ?? []).filter((s) => s.capacity > 0);
return (
<div className="bo-col" style={{ gap: 12 }}>
<div className="bo-days" role="group" aria-label={t('no.day')}>
{days.map((k) => {
const d = atYerevan(k, '12:00');
return (
<button
key={k}
type="button"
className="bo-day"
aria-pressed={k === day}
onClick={() => {
setDay(k);
setPicked(null);
}}
>
<span>{formatDateTime(locale as Locale, d, { weekday: 'short' })}</span>
<b>{formatDateTime(locale as Locale, d, { day: 'numeric' })}</b>
</button>
);
})}
</div>
{!custom ? (
free.length === 0 ? (
<p className="bo-hint">{slots.isFetching ? t('ui.loading') : t('no.noSlots')}</p>
) : (
<div className="bo-slots">
{free.map((s) => (
<button key={s.start.toISOString()} type="button" className="bo-slot" aria-pressed={picked?.getTime() === s.start.getTime()} onClick={() => setPicked(s.start)}>
{time(s.start)}
<span>{s.capacity}</span>
</button>
))}
</div>
)
) : (
<Field label={t('no.customTime')} hint={t('no.customHint')}>
<input className="bo-input" type="time" value={hhmm} onChange={(e) => setHhmm(e.target.value)} step={900} />
</Field>
)}
<label className="bo-check">
<input type="checkbox" checked={custom} onChange={(e) => setCustom(e.target.checked)} />
{t('no.otherTime')}
</label>
</div>
);
}
/** Новый заказ за клиента: услуга → параметры → адрес → время. Цена — с его скидкой по подписке */
export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?: string; onClose: () => void }) {
const { t, shared, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [clientId, setClientId] = useState(fixedClient ?? '');
const services = useQuery(trpc.bo.orders.services.queryOptions());
const dict = useQuery(trpc.bo.orders.dictionaries.queryOptions());
const [svc, setSvc] = useState('');
const service = services.data?.find((s) => s.code === svc) ?? services.data?.[0];
const fields = (service?.paramsSchema.fields ?? []) as ParamField[];
const [params, setParams] = useState<Params>({});
useEffect(() => {
if (service) setParams(defaults(fields));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [service?.code]);
const [address, setAddress] = useState('');
const [districtId, setDistrictId] = useState('');
const [note, setNote] = useState('');
const [payment, setPayment] = useState('');
const [at, setAt] = useState<{ when: Date; busy: boolean } | null>(null);
const [promo, setPromo] = useState('');
const [promoQ, setPromoQ] = useState('');
useEffect(() => {
const id = setTimeout(() => setPromoQ(promo.trim()), 400);
return () => clearTimeout(id);
}, [promo]);
const ready = !!service && Object.keys(params).length > 0;
const quote = useQuery({ ...trpc.bo.orders.quote.queryOptions({ clientId, service: service?.code ?? '', params, promoCode: promoQ || undefined }), enabled: ready && !!clientId, placeholderData: keepPreviousData, retry: false });
const create = useMutation(
trpc.bo.orders.create.mutationOptions({
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: trpc.bo.orders.list.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.clients.get.queryKey() });
toast(t('no.created', { n: r.number }));
onClose();
navigate(`/orders/${r.id}`);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const needsAddress = service && !service.isOnline;
const canSubmit = !!clientId && ready && !!at && (!needsAddress || (address.trim() && districtId)) && !create.isPending;
return (
<Dialog
open
wide
onClose={onClose}
title={t('no.title')}
footer={
<>
{quote.data && (
<span className="bo-grow bo-quote">
<b className="bo-num">{amd(quote.data.total)}</b>
<span className="bo-small bo-muted">
{' '}
· {t('ord.minutes', { n: quote.data.durationMinutes })}
{quote.data.discountPct > 0 && ` · −${quote.data.discountPct}%`}
</span>
</span>
)}
<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={!canSubmit}
onClick={() =>
at &&
service &&
create.mutate({
clientId,
service: service.code,
params,
scheduledAt: at.when,
allowBusySlot: at.busy,
addressText: needsAddress ? address : undefined,
districtId: needsAddress ? districtId : undefined,
note: note || undefined,
paymentMethodId: payment || undefined,
promoCode: promoQ && !quote.isError ? promoQ : undefined,
})
}
>
{t('no.create')}
</button>
</>
}
>
{!fixedClient && <ClientPicker value={clientId} onChange={setClientId} />}
{(services.data?.length ?? 0) > 1 && (
<Field label={t('no.service')}>
<select className="bo-select" value={service?.code ?? ''} onChange={(e) => setSvc(e.target.value)}>
{services.data!.map((s) => (
<option key={s.code} value={s.code}>
{shared(`services.${s.code}.name`)}
</option>
))}
</select>
</Field>
)}
{service && <ParamsForm service={service.code} fields={fields} value={params} onChange={setParams} />}
{needsAddress && (
<div className="bo-row2">
<Field label={t('no.address')}>
<input className="bo-input" value={address} maxLength={300} onChange={(e) => setAddress(e.target.value)} placeholder={t('no.addressPh')} />
</Field>
<Field label={t('no.district')}>
<select className="bo-select" value={districtId} onChange={(e) => setDistrictId(e.target.value)}>
<option value="">—</option>
{dict.data?.districts.map((d) => (
<option key={d.id} value={d.id}>
{shared(`districts.${d.code}`)}
</option>
))}
</select>
</Field>
</div>
)}
{ready && (
<div>
<span className="bo-label">{t('no.when')}</span>
<div style={{ marginTop: 6 }}>
<SlotPicker service={service!.code} params={params} onChange={setAt} />
</div>
</div>
)}
<div className="bo-row2">
<Field label={t('no.note')}>
<input className="bo-input" value={note} maxLength={1000} onChange={(e) => setNote(e.target.value)} placeholder={t('no.notePh')} />
</Field>
<Field label={t('no.payment')}>
<select className="bo-select" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="">—</option>
{dict.data?.paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name.ru ?? m.code}
</option>
))}
</select>
</Field>
</div>
<Field
label={t('no.promo')}
hint={promoQ ? (quote.isError ? errText(t, quote.error) : quote.data?.promo ? t('no.promoOk', { code: quote.data.promo }) : quote.data ? t('no.promoWeaker') : undefined) : undefined}
>
<input className="bo-input" value={promo} maxLength={40} onChange={(e) => setPromo(e.target.value.toUpperCase())} placeholder={t('no.promoPh')} style={{ maxWidth: 260 }} />
</Field>
<p className="bo-hint">{t('no.hint')}</p>
</Dialog>
);
}
export function ParamsForm({ service, fields, value, onChange }: { service: string; fields: ParamField[]; value: Params; onChange: (v: Params) => void }) {
const { t, shared } = useT();
const label = (k: string) => {
const key = `params.${service}.${k}.label`;
const v = shared(key);
return v === key ? k : v;
};
const opt = (k: string, o: string) => {
const key = `params.${service}.${k}.${o}`;
const v = shared(key);
return v === key ? o : v;
};
const set = (k: string, v: unknown) => onChange({ ...value, [k]: v });
const [showExtras, setShowExtras] = useState(false);
return (
<div className="bo-col" style={{ gap: 12 }}>
<div className="bo-row2">
{fields
.filter((f) => f.type === 'select' || f.type === 'int')
.map((f) =>
f.type === 'select' ? (
<Field key={f.key} label={label(f.key)}>
<select className="bo-select" value={String(value[f.key] ?? '')} onChange={(e) => set(f.key, e.target.value)}>
{f.options.map((o) => (
<option key={o} value={o}>
{opt(f.key, o)}
</option>
))}
</select>
</Field>
) : f.type === 'int' ? (
<Field key={f.key} label={label(f.key)}>
<Stepper value={Number(value[f.key] ?? f.min)} min={f.min} max={f.max} onChange={(n) => set(f.key, n)} />
</Field>
) : null,
)}
</div>
{fields.map((f) =>
f.type === 'text' ? (
<Field key={f.key} label={label(f.key)}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} maxLength={f.maxLength} value={String(value[f.key] ?? '')} onChange={(e) => set(f.key, e.target.value)} />
</Field>
) : f.type === 'bool' ? (
<label key={f.key} className="bo-check">
<input type="checkbox" checked={!!value[f.key]} onChange={(e) => set(f.key, e.target.checked)} />
{label(f.key)}
</label>
) : f.type === 'extras' ? (
<div key={f.key}>
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0 }} onClick={() => setShowExtras((s) => !s)} aria-expanded={showExtras}>
{t('no.extras')} {Object.values((value[f.key] as Record<string, number>) ?? {}).filter((n) => n > 0).length || ''} {showExtras ? '▴' : '▾'}
</button>
{showExtras && (
<div className="bo-extras">
{f.items.map((it) => {
const ex = (value[f.key] as Record<string, number>) ?? {};
const n = ex[it.code] ?? 0;
return (
<div key={it.code} className="bo-extra" data-on={n > 0 || undefined}>
<span className="bo-grow">{shared(`extras.${it.code}.name`)}</span>
{it.max === 1 ? (
<input type="checkbox" checked={n > 0} onChange={(e) => set(f.key, { ...ex, [it.code]: e.target.checked ? 1 : 0 })} aria-label={shared(`extras.${it.code}.name`)} />
) : (
<Stepper value={n} min={0} max={it.max} onChange={(v) => set(f.key, { ...ex, [it.code]: v })} small />
)}
</div>
);
})}
</div>
)}
</div>
) : null,
)}
</div>
);
}
function Stepper({ value, min, max, onChange, small }: { value: number; min: number; max: number; onChange: (n: number) => void; small?: boolean }) {
return (
<span className={`bo-stepper${small ? ' bo-stepper--sm' : ''}`}>
<button type="button" onClick={() => onChange(Math.max(min, value - 1))} disabled={value <= min} aria-label="−">
−
</button>
<b className="bo-num">{value}</b>
<button type="button" onClick={() => onChange(Math.min(max, value + 1))} disabled={value >= max} aria-label="+">
+
</button>
</span>
);
}
function ClientPicker({ value, onChange }: { value: string; onChange: (id: string) => void }) {
const { t } = useT();
const trpc = useTRPC();
const [q, setQ] = useState('');
const [debounced, setDebounced] = useState('');
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), 250);
return () => clearTimeout(id);
}, [q]);
const list = useQuery({ ...trpc.bo.clients.list.queryOptions({ search: debounced || undefined, sort: 'new', page: 0 }), enabled: debounced.length >= 2 && !value, placeholderData: keepPreviousData });
const picked = useQuery({ ...trpc.bo.clients.get.queryOptions({ id: value }), enabled: !!value });
if (value && picked.data)
return (
<div className="bo-person">
<Avatar name={picked.data.name} id={picked.data.id} size={36} />
<span className="bo-grow">
<b>{picked.data.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[picked.data.username && `@${picked.data.username}`, picked.data.phone].filter(Boolean).join(' · ')}
</span>
</span>
<button type="button" className="bo-iconbtn" onClick={() => onChange('')} aria-label={t('no.changeClient')}>
<I name="close" size={18} />
</button>
</div>
);
return (
<Field label={t('no.client')}>
<input className="bo-input" autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('no.clientPh')} />
{debounced.length >= 2 && (
<ul className="bo-picklist">
{(list.data?.items ?? []).slice(0, 8).map((c) => (
<li key={c.id}>
<button type="button" onClick={() => onChange(c.id)}>
<Avatar name={c.name} id={c.id} size={28} />
<span className="bo-grow">
{c.name}
<span className="bo-faint bo-small"> {[c.username && `@${c.username}`, c.phone].filter(Boolean).join(' · ')}</span>
</span>
<PlanBadge plan={c.sub?.plan ?? null} t={t} />
</button>
</li>
))}
{list.data && list.data.items.length === 0 && <li className="bo-hint" style={{ padding: 8 }}>{t('no.noClients')}</li>}
</ul>
)}
</Field>
);
}
-773
View File
@@ -1,773 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, paramsExtras, paramsSummary, useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge, Skeleton } from '../components/kit.tsx';
import { GROUP_DOT, statusLabel } from './Orders.tsx';
import { ParamsForm, SlotPicker, type ParamField } from './NewOrder.tsx';
const MIN = 60_000;
export function OrderCard({ id, me }: { id: string; me: Me }) {
const { t, shared, sharedT, date, dateTime, time, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const o = useQuery({ ...trpc.bo.orders.get.queryOptions({ id }), refetchInterval: 15_000 });
const [dlg, setDlg] = useState<'reschedule' | 'cancel' | 'edit' | null>(null);
const [check, setCheck] = useState<{ code: string; requiresPhoto: boolean } | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.orders.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.orders.candidates.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.orders.list.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.orders.counts.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.dashboard.attention.queryKey() });
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const transition = useMutation(trpc.bo.orders.transition.mutationOptions({ onSuccess: () => (refresh(), toast(t('ord.statusChanged'))), onError }));
const markPaid = useMutation(trpc.bo.orders.markPaid.mutationOptions({ onSuccess: () => (refresh(), toast(t('ord.paidDone'))), onError }));
const setChecklist = useMutation(trpc.bo.orders.checklist.mutationOptions({ onSuccess: refresh, onError }));
const chat = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError }));
const canChat = me.roles.some((r) => r === 'SUPERADMIN' || r === 'SUPPORT' || r === 'CONCIERGE');
const back = (
<a className="bo-back" href="#/orders">
<I name="back" size={16} />
{t('ord.back')}
</a>
);
if (o.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, o.error)}</div>
</>
);
if (!o.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={260} />
</div>
);
const d = o.data;
const end = new Date(d.scheduledAt.getTime() + d.durationMinutes * MIN);
const extras = paramsExtras(sharedT, d.params);
const stepIdx = d.steps.indexOf(d.status as never);
return (
<>
{back}
<header className="bo-ohead">
<div style={{ minWidth: 0 }}>
<div className="bo-ohead__kicker">
<span className="bo-num">№{d.number}</span>
<span className="bo-status" style={{ ['--dot' as string]: GROUP_DOT[d.status === 'CANCELLED' ? 'cancelled' : d.group === 'new' && d.pending ? 'pending' : d.group] }}>
{statusLabel(shared, d.flow, d.status)}
</span>
</div>
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
{shared(`services.${d.service}.name`)}
</h1>
<div className="bo-muted" style={{ marginTop: 6 }}>
{dateTime(d.scheduledAt)} – {time(end)}
{d.service === 'cleaning' && <> · {paramsSummary(sharedT, d.service, d.params)}</>}
</div>
</div>
</header>
{d.status !== 'CANCELLED' && (
<ol className="bo-steps" aria-label={t('ord.progress')}>
{d.steps.map((s, i) => (
<li key={s} data-state={i < stepIdx ? 'done' : i === stepIdx ? 'now' : 'next'}>
{statusLabel(shared, d.flow, s)}
</li>
))}
</ol>
)}
<div className="bo-client-grid">
<div className="bo-col">
<ProviderPanel order={d} onChanged={refresh} canChat={canChat} />
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.details')}</h2>
{d.status !== 'CANCELLED' && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => setDlg('edit')}>
{t('ord.editComposition')}
</button>
)}
</div>
<dl className="bo-kv">
<dt>{t('ord.when')}</dt>
<dd>
{date(d.scheduledAt)}, {time(d.scheduledAt)} – {time(end)}
<span className="bo-faint"> · {t('ord.minutes', { n: d.durationMinutes })}</span>
</dd>
<dt>{t('ord.where')}</dt>
<dd>{d.isOnline ? shared('common.online') : [d.address, d.district ? shared(`districts.${d.district}`) : null].filter(Boolean).join(', ') || '—'}</dd>
{d.service === 'cleaning' && (
<>
<dt>{t('ord.params')}</dt>
<dd>{paramsSummary(sharedT, d.service, d.params)}</dd>
</>
)}
{d.service !== 'cleaning' &&
Object.entries(d.params)
.filter(([, v]) => typeof v === 'string' || typeof v === 'number')
.map(([k, v]) => (
<FragmentKV key={k} k={paramLabel(shared, d.service, k)} v={paramValue(shared, d.service, k, v)} />
))}
{extras.length > 0 && (
<>
<dt>{t('ord.extras')}</dt>
<dd>{extras.map((x) => x.label).join(', ')}</dd>
</>
)}
{d.note && (
<>
<dt>{t('ord.note')}</dt>
<dd style={{ whiteSpace: 'pre-line' }}>{d.note}</dd>
</>
)}
<dt>{t('ord.lang')}</dt>
<dd>{d.locale.toUpperCase()}</dd>
<dt>{t('ord.created')}</dt>
<dd>{dateTime(d.createdAt)}</dd>
</dl>
</section>
{d.checklist.length > 0 && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.checklist')}</h2>
<span className="bo-faint">
{d.checklist.filter((c) => c.done).length}/{d.checklist.length}
</span>
</div>
<ul className="bo-checks">
{d.checklist.map((c) => (
<li key={c.code} data-done={c.done || undefined}>
<button
type="button"
className="bo-checks__toggle"
disabled={setChecklist.isPending || d.status === 'CANCELLED'}
title={c.done ? t('ord.uncheck') : t('ord.checkForProvider')}
onClick={() => (c.done ? setChecklist.mutate({ id: d.id, code: c.code, checked: false }) : c.requiresPhoto && !c.fileId ? setCheck(c) : setChecklist.mutate({ id: d.id, code: c.code, checked: true }))}
>
<span className="bo-checks__box">{c.done && <I name="check" size={14} stroke={3} />}</span>
{shared(`checklist.${c.code}`)}
</button>
{c.requiresPhoto && !c.fileId && !c.waived && <span className="bo-faint bo-small"> · {t('ord.needsPhoto')}</span>}
{c.waived && <span className="bo-faint bo-small"> · {t('ord.noPhotoReason', { reason: c.waived })}</span>}
{c.byStaff && <span className="bo-faint bo-small"> · {t('ord.byStaff')}</span>}
</li>
))}
</ul>
{d.photos.length > 0 && (
<div className="bo-photos">
{d.photos.map((f) => (
<a key={f} href={`/files/bo/order-file/${f}`} target="_blank" rel="noreferrer">
<img src={`/files/bo/order-file/${f}`} alt={t('ord.photo')} loading="lazy" />
</a>
))}
</div>
)}
</section>
)}
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.history')}</h2>
</div>
<ol className="bo-timeline">
{d.history.map((h) => (
<li key={h.id}>
<span className="bo-feed__time">{dateTime(h.at)}</span>
<span>
<b>{historyTitle(t, shared, d.flow, h)}</b>
<span className="bo-muted">
{' '}
· {h.actorName ?? shared(`admin.actors.${h.actor}`)}
{h.comment && ` · ${h.comment}`}
</span>
</span>
</li>
))}
</ol>
</section>
</div>
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.client')}</h2>
</div>
<a href={`#/clients/${d.client.id}`} className="bo-person">
<Avatar name={d.client.name} id={d.client.id} size={40} />
<span style={{ minWidth: 0 }}>
<b>{d.client.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[d.client.username && `@${d.client.username}`, d.client.phone].filter(Boolean).join(' · ') || '—'}
</span>
</span>
<PlanBadge plan={d.client.plan} t={t} />
</a>
{canChat && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" style={{ marginTop: 12 }} onClick={() => chat.mutate({ clientId: d.client.id, channel: 'SUPPORT' })}>
<I name="chats" size={16} />
{t('ord.writeClient')}
</button>
)}
</section>
{d.money && (
<section className="bo-panel bo-money-card">
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
{t('ord.money')}
</span>
<div className="bo-bignum" style={{ margin: '6px 0 10px' }}>
{amd(d.money.total)}
</div>
<dl className="bo-kv bo-kv--plain">
<dt>{t('ord.base')}</dt>
<dd className="bo-num">{amd(d.money.base)}</dd>
{d.money.discount > 0 && (
<>
<dt>{t('ord.discount', { pct: d.money.discountPct })}</dt>
<dd className="bo-num">−{amd(d.money.discount)}</dd>
</>
)}
{d.money.payout != null && (
<>
<dt>{t('ord.payout')}</dt>
<dd className="bo-num">{amd(d.money.payout)}</dd>
</>
)}
{d.money.cancelFee > 0 && (
<>
<dt>{t('ord.cancelFee')}</dt>
<dd className="bo-num">{amd(d.money.cancelFee)}</dd>
</>
)}
</dl>
</section>
)}
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.payment')}</h2>
<span className={`bo-chip${d.paymentStatus === 'PAID' ? ' bo-chip--ok' : ''}`}>{shared(`payment.${d.paymentStatus}`)}</span>
</div>
{d.paidAt && (
<p className="bo-hint">
{dateTime(d.paidAt)}
{d.paymentMethod && ` · ${d.paymentMethod.ru}`}
</p>
)}
{d.paymentStatus === 'UNPAID' && d.status !== 'CANCELLED' && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => confirm(t('ord.confirmPaid')) && markPaid.mutate({ id })} disabled={markPaid.isPending}>
<I name="check" size={16} />
{t('ord.markPaid')}
</button>
)}
</section>
{(d.adminTransitions.length > 0 || d.canReschedule || d.canCancel) && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.actions')}</h2>
</div>
<div className="bo-col" style={{ gap: 8 }}>
{d.adminTransitions.map((to) => (
<button
key={to}
type="button"
className="bo-btn bo-btn--ghost bo-btn--sm"
disabled={transition.isPending}
onClick={() => confirm(t('ord.confirmStatus', { status: statusLabel(shared, d.flow, to) })) && transition.mutate({ id, to })}
>
{t('ord.setStatus', { status: statusLabel(shared, d.flow, to) })}
</button>
))}
{d.canReschedule && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg('reschedule')}>
{t('ord.reschedule')}
</button>
)}
{d.canCancel && (
<button type="button" className="bo-btn bo-btn--danger bo-btn--sm" onClick={() => setDlg('cancel')}>
{t('ord.cancel')}
</button>
)}
</div>
</section>
)}
{d.rating && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.rating')}</h2>
<span className="bo-stars">{'★'.repeat(d.rating.score)}{'☆'.repeat(5 - d.rating.score)}</span>
</div>
{d.rating.comment ? <p style={{ margin: 0 }}>{d.rating.comment}</p> : <p className="bo-hint">{t('ord.noComment')}</p>}
</section>
)}
{d.offers.length > 0 && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.offers')}</h2>
</div>
<ul className="bo-list">
{d.offers.map((x) => (
<li key={x.id}>
<span className="bo-grow">
<b>{x.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{dateTime(x.createdAt)} · {x.by ?? t('ord.bySystem')}
</span>
</span>
<span className="bo-chip">{shared(`admin.offerStatus.${x.status}`)}</span>
</li>
))}
</ul>
</section>
)}
{d.cancelReason && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.cancelReason')}</h2>
</div>
<p style={{ margin: 0 }}>{d.cancelReason}</p>
</section>
)}
</div>
</div>
{dlg === 'reschedule' && <RescheduleDialog order={d} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'edit' && <EditDialog order={d} onClose={() => setDlg(null)} onDone={refresh} />}
{check && <ChecklistPhotoDialog orderId={d.id} code={check.code} onClose={() => setCheck(null)} onDone={refresh} />}
{dlg === 'cancel' && <CancelDialog id={id} showFee={!!d.money} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
function FragmentKV({ k, v }: { k: string; v: string }) {
return (
<>
<dt>{k}</dt>
<dd>{v}</dd>
</>
);
}
const paramLabel = (shared: (k: string) => string, svc: string, key: string) => {
const k = `params.${svc}.${key}.label`;
const v = shared(k);
return v === k ? key : v;
};
const paramValue = (shared: (k: string) => string, svc: string, key: string, value: unknown) => {
const k = `params.${svc}.${key}.${String(value)}`;
const v = shared(k);
return v === k ? String(value) : v;
};
type HistoryRow = { event: string | null; from: string | null; to: string; comment: string | null };
function historyTitle(t: ReturnType<typeof useT>['t'], shared: (k: string) => string, flow: string, h: HistoryRow) {
if (h.event) {
const own = `ord.ev.${h.event}`;
const ownTr = t(own);
if (ownTr !== own) return ownTr;
const sh = `admin.events.${h.event}`;
const shTr = shared(sh);
if (shTr !== sh) return shTr;
}
if (!h.from) return t('ord.ev.created');
return statusLabel(shared, flow, h.to);
}
// ───── исполнитель и подбор ─────
type OrderData = NonNullable<ReturnType<typeof useOrderData>>;
function useOrderData(id: string) {
const trpc = useTRPC();
return useQuery(trpc.bo.orders.get.queryOptions({ id })).data;
}
function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onChanged: () => void; canChat: boolean }) {
const { t, shared, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const [showBusy, setShowBusy] = useState(false);
const onError = (e: unknown) => toast(errText(t, e), 'error');
const cands = useQuery({ ...trpc.bo.orders.candidates.queryOptions({ id: d.id }), enabled: d.canAssign && !d.pending });
const assign = useMutation(trpc.bo.orders.assign.mutationOptions({ onSuccess: () => (onChanged(), toast(t('ord.offered'))), onError }));
const auto = useMutation(
trpc.bo.orders.auto.mutationOptions({
onSuccess: (r) => {
onChanged();
toast(r.result === 'offered' ? t('ord.autoOffered', { name: r.target }) : t('ord.autoNone'), r.result === 'offered' ? undefined : 'error');
},
onError,
}),
);
const unassign = useMutation(trpc.bo.orders.unassign.mutationOptions({ onSuccess: () => (onChanged(), toast(t('ord.unassigned'))), onError }));
const chat = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError }));
const [left, setLeft] = useState('');
useEffect(() => {
const exp = d.pending?.expiresAt;
if (!exp) return setLeft('');
const tick = () => setLeft(t('ord.minLeft', { n: Math.max(0, Math.ceil((exp.getTime() - Date.now()) / MIN)) }));
tick();
const iv = setInterval(tick, 15_000);
return () => clearInterval(iv);
}, [d.pending?.expiresAt, t]);
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.provider')}</h2>
{d.canUnassign && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={unassign.isPending} onClick={() => confirm(d.provider ? t('ord.confirmUnassign') : t('ord.confirmRevoke')) && unassign.mutate({ id: d.id })}>
{d.provider ? t('ord.unassign') : t('ord.revoke')}
</button>
)}
</div>
{d.provider ? (
<>
<div className="bo-person">
<Avatar name={d.provider.name} id={d.provider.id} size={40} />
<a href={`#/providers/${d.provider.id}`} style={{ minWidth: 0, color: 'inherit', textDecoration: 'none' }}>
<b>{d.provider.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[d.company?.name, d.provider.phone, d.provider.username && `@${d.provider.username}`].filter(Boolean).join(' · ') || '—'}
</span>
</a>
</div>
<ContactRow providerId={d.provider.id} phone={d.provider.phone} canChat={canChat} onChat={(id) => chat.mutate({ providerId: id })} busy={chat.isPending} />
</>
) : d.pending ? (
<>
<div className="bo-pendingbox">
{d.pending.auto ? <I name="bot" size={20} /> : <I name="send" size={20} />}
<span className="bo-grow">
<b>{d.pending.company ? t('ord.pendingCompany', { name: d.pending.name }) : t('ord.pendingProvider', { name: d.pending.name })}</b>
<span className="bo-small" style={{ display: 'block', opacity: 0.8 }}>
{[d.pending.auto ? t('ord.byAuto') : null, left].filter(Boolean).join(' · ')}
</span>
</span>
</div>
{d.pending.providerId && <ContactRow providerId={d.pending.providerId} phone={d.pending.phone} canChat={canChat} onChat={(id) => chat.mutate({ providerId: id })} busy={chat.isPending} />}
</>
) : d.canAssign ? (
<>
<div className="bo-assignbar">
<p className="bo-hint bo-grow" style={{ margin: 0 }}>
{t('ord.pickHint')}
</p>
<button type="button" className="bo-btn bo-btn--sm" onClick={() => auto.mutate({ id: d.id })} disabled={auto.isPending}>
<I name="bot" size={16} />
{t('ord.autoPick')}
</button>
</div>
{!cands.data ? (
<Skeleton h={120} />
) : cands.data.free.length === 0 ? (
<p className="bo-empty" style={{ padding: '16px 0' }}>
{t('ord.noFree')}
</p>
) : (
<ul className="bo-cands">
{cands.data.free.map((c, i) => (
<li key={`${c.kind}:${c.id}`} data-best={c.best || undefined}>
<span className="bo-cands__rank">{i + 1}</span>
<span className="bo-grow" style={{ minWidth: 0 }}>
<b>{c.name}</b>
{c.kind === 'company' && <span className="bo-chip" style={{ marginLeft: 6 }}>{t('ord.partner')}</span>}
{c.best && <span className="bo-chip bo-chip--ok" style={{ marginLeft: 6 }}>{t('ord.best')}</span>}
{c.tried && <span className="bo-chip bo-chip--warn" style={{ marginLeft: 6 }}>{t('ord.tried')}</span>}
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[
c.rating != null ? `★ ${c.rating}` : null,
c.languages.join(', ').toUpperCase(),
...c.reasons.map((r) => shared(`admin.reasons.${r}`)),
c.kind === 'company' && c.partnerFree != null ? t('ord.partnerFree', { n: c.partnerFree, of: c.partnerStaff ?? 0 }) : t('ord.load', { n: c.load }),
]
.filter(Boolean)
.join(' · ')}
</span>
{c.margin != null && (
<span className="bo-small" style={{ display: 'block' }}>
{t('ord.candMoney', { cost: amd(c.cost ?? 0), margin: amd(c.margin) })}
</span>
)}
</span>
<button type="button" className="bo-btn bo-btn--sm" disabled={assign.isPending} onClick={() => assign.mutate({ id: d.id, target: { kind: c.kind, id: c.id } })}>
{t('ord.offer')}
</button>
</li>
))}
</ul>
)}
{!!cands.data?.busy.length && (
<>
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-ink-2)', marginTop: 10 }} onClick={() => setShowBusy((s) => !s)}>
{t('ord.busy', { n: cands.data.busy.length })} {showBusy ? '▴' : '▾'}
</button>
{showBusy && <p className="bo-hint" style={{ marginTop: 6 }}>{cands.data.busy.map((c) => c.name).join(', ')}</p>}
</>
)}
</>
) : (
<p className="bo-hint">{t('ord.noProvider')}</p>
)}
</section>
);
}
/** Связаться с исполнителем: чат в рабочем боте и звонок */
function ContactRow({ providerId, phone, canChat, onChat, busy }: { providerId: string; phone: string | null; canChat: boolean; onChat: (id: string) => void; busy: boolean }) {
const { t } = useT();
return (
<div className="bo-contact">
{canChat && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => onChat(providerId)} disabled={busy}>
<I name="chats" size={16} />
{t('ord.writeProvider')}
</button>
)}
{phone ? (
<a className="bo-btn bo-btn--ghost bo-btn--sm" href={`tel:${phone.replace(/[^\d+]/g, '')}`}>
<I name="phone" size={16} />
{t('ord.call')}
</a>
) : (
<span className="bo-btn bo-btn--ghost bo-btn--sm" aria-disabled="true" title={t('ord.noPhone')} style={{ opacity: 0.45, cursor: 'default' }}>
<I name="phone" size={16} />
{t('ord.call')}
</span>
)}
</div>
);
}
// ───── диалоги ─────
function RescheduleDialog({ order: d, onClose, onDone }: { order: OrderData; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [at, setAt] = useState<{ when: Date; busy: boolean } | null>(null);
const [comment, setComment] = useState('');
const save = useMutation(
trpc.bo.orders.reschedule.mutationOptions({
onSuccess: (r) => {
onDone();
toast(r.keptProvider ? t('ord.rescheduled') : t('ord.rescheduledFree'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('ord.rescheduleTitle', { n: d.number })}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!at || save.isPending} onClick={() => at && save.mutate({ id: d.id, scheduledAt: at.when, allowBusySlot: at.busy, comment: comment || undefined })}>
{t('ord.reschedule')}
</button>
</>
}
>
<SlotPicker service={d.service} params={d.params} excludeOrderId={d.id} initial={d.scheduledAt} onChange={setAt} />
<Field label={t('ord.comment')}>
<input className="bo-input" value={comment} maxLength={500} onChange={(e) => setComment(e.target.value)} placeholder={t('ord.commentPh')} />
</Field>
{d.provider && <p className="bo-hint">{t('ord.rescheduleHint')}</p>}
</Dialog>
);
}
function CancelDialog({ id, showFee, onClose, onDone }: { id: string; showFee: boolean; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [reason, setReason] = useState('');
const [fee, setFee] = useState('');
const cancel = useMutation(
trpc.bo.orders.cancel.mutationOptions({
onSuccess: () => {
onDone();
toast(t('ord.cancelled'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('ord.cancelTitle')}
footer={
<>
<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={!reason.trim() || cancel.isPending} onClick={() => cancel.mutate({ id, reason, fee: Number(fee) || 0 })}>
{t('ord.cancel')}
</button>
</>
}
>
<Field label={t('ord.cancelReason')}>
<textarea className="bo-textarea" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.cancelReasonPh')} />
</Field>
{showFee && (
<Field label={t('ord.cancelFee')} hint={t('ord.cancelFeeHint')}>
<input className="bo-input" inputMode="numeric" value={fee} onChange={(e) => setFee(e.target.value.replace(/\D/g, ''))} placeholder="0" />
</Field>
)}
<p className="bo-hint">{t('ord.cancelHint')}</p>
</Dialog>
);
}
/** Изменить состав заказа: пересчёт по прайсу с прежней скидкой или своя цена с причиной */
function EditDialog({ order, onClose, onDone }: { order: { id: string; number: number; service: string; params: Record<string, unknown> }; onClose: () => void; onDone: () => void }) {
const { t, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const services = useQuery(trpc.bo.orders.services.queryOptions());
const fields = (services.data?.find((s) => s.code === order.service)?.paramsSchema.fields ?? []) as ParamField[];
const [params, setParams] = useState<Record<string, unknown>>(() => ({ ...order.params }));
const [price, setPrice] = useState('');
const [reason, setReason] = useState('');
const [notify, setNotify] = useState(true);
const p = useQuery({ ...trpc.bo.orders.editPreview.queryOptions({ id: order.id, params }), placeholderData: (prev) => prev });
const manual = price !== '' && p.data && Number(price) !== p.data.totalPrice;
const save = useMutation(
trpc.bo.orders.edit.mutationOptions({
onSuccess: (r) => {
toast(r.providerBusy ? t('ord.editedBusy') : t('ord.edited'));
onDone();
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
wide
onClose={onClose}
title={t('ord.editTitle', { n: order.number })}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!p.data || save.isPending || (!!manual && !reason.trim())} onClick={() => save.mutate({ id: order.id, params, totalPrice: price === '' ? null : Number(price), reason: reason.trim() || null, notify })}>
{t('ui.save')}
</button>
</>
}
>
{fields.length > 0 && <ParamsForm service={order.service} fields={fields} value={params} onChange={setParams} />}
{p.isError && <p className="bo-hint" style={{ color: 'var(--bc-danger)' }}>{errText(t, p.error)}</p>}
{p.data && (
<div className="bo-side-panel__note" style={{ margin: '14px 0' }}>
{t('ord.editPrice', { was: amd(p.data.was.totalPrice), now: amd(p.data.totalPrice), pct: p.data.discountPct })}
<br />
{t('ord.editDuration', { was: p.data.was.durationMinutes, now: p.data.durationMinutes })}
{p.data.providerBusy && (
<>
<br />⚠️ {t('ord.editBusy')}
</>
)}
{p.data.paid && (
<>
<br />💳 {t('ord.editPaid')}
</>
)}
</div>
)}
<div className="bo-row2">
<Field label={t('ord.editOwnPrice')} hint={p.data ? t('ord.editOwnPriceHint', { n: amd(p.data.totalPrice) }) : undefined}>
<input className="bo-input" inputMode="numeric" value={price} placeholder={p.data ? String(p.data.totalPrice) : ''} onChange={(e) => setPrice(e.target.value.replace(/\D/g, '').slice(0, 8))} />
</Field>
<Field label={manual ? t('ord.editReasonRequired') : t('ord.editReason')}>
<input className="bo-input" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.editReasonPh')} />
</Field>
</div>
<label className="bo-check">
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} />
{t('ord.editNotify')}
</label>
</Dialog>
);
}
/** Пункт с фото: загрузить фото или отметить без фото с причиной */
function ChecklistPhotoDialog({ orderId, code, onClose, onDone }: { orderId: string; code: string; onClose: () => void; onDone: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const toast = useToast();
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const mark = useMutation(trpc.bo.orders.checklist.mutationOptions({ onSuccess: () => (onDone(), onClose()), onError: (e) => toast(errText(t, e), 'error') }));
const upload = async (file: File) => {
setBusy(true);
const fd = new FormData();
fd.append('code', code);
fd.append('file', file);
const res = await fetch(`/files/bo/checklist-photo/${orderId}`, { method: 'POST', body: fd, credentials: 'include' }).catch(() => null);
setBusy(false);
if (!res?.ok) {
const err = (await res?.json().catch(() => null)) as { error?: string } | null;
toast(errText(t, { message: err?.error ?? 'NETWORK' }), 'error');
return;
}
onDone();
onClose();
};
return (
<Dialog
open
onClose={onClose}
title={shared(`checklist.${code}`)}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!reason.trim() || mark.isPending} onClick={() => mark.mutate({ id: orderId, code, checked: true, reason })}>
{t('ord.checkNoPhoto')}
</button>
</>
}
>
<p className="bo-hint" style={{ marginTop: 0 }}>
{t('ord.photoHint')}
</p>
<label className="bo-btn bo-btn--accent" style={{ display: 'inline-flex', marginBottom: 14, opacity: busy ? 0.6 : 1 }}>
<I name="clip" size={16} />
{busy ? t('ui.loading') : t('ord.uploadPhoto')}
<input type="file" accept="image/*" hidden disabled={busy} onChange={(e) => e.target.files?.[0] && void upload(e.target.files[0])} />
</label>
<Field label={t('ord.noPhotoField')}>
<input className="bo-input" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.noPhotoPh')} />
</Field>
</Dialog>
);
}
-234
View File
@@ -1,234 +0,0 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDateTime, type Locale } from '@barev/i18n';
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 { I, Skeleton } from '../components/kit.tsx';
import { NewOrderDialog } from './NewOrder.tsx';
import { OrdersTabs } from './Moves.tsx';
type Group = 'active' | 'new' | 'pending' | 'assigned' | 'in_progress' | 'done' | 'cancelled' | 'all';
type Period = 'all' | 'today' | 'tomorrow' | 'week' | 'past';
const GROUPS: Group[] = ['active', 'new', 'pending', 'assigned', 'in_progress', 'done', 'cancelled'];
const PERIODS: Period[] = ['all', 'today', 'tomorrow', 'week', 'past'];
const MIN = 60_000;
/** Цвет группы — как точка статуса */
export const GROUP_DOT: Record<string, string> = {
new: 'var(--bc-accent)',
pending: 'var(--bc-perks-ink)',
assigned: 'var(--bc-blue-ink)',
in_progress: 'var(--bc-ok)',
done: 'var(--bc-ink-3)',
cancelled: 'var(--bc-danger)',
};
export function Orders({ me }: { me: Me }) {
const { t, locale, shared, amd, time } = useT();
const trpc = useTRPC();
const { query } = useLocation();
const group = (query.get('group') as Group) || 'active';
const period = (query.get('period') as Period) || 'all';
const [search, setSearch] = useState(query.get('q') ?? '');
const [q, setQ] = useState(search);
const [creating, setCreating] = useState(false);
const isSuper = me.roles.includes('SUPERADMIN');
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const set = (patch: { group?: Group; period?: Period }) => {
const p = new URLSearchParams();
const g = patch.group ?? group;
const per = patch.period ?? period;
if (g !== 'active') p.set('group', g);
if (per !== 'all') p.set('period', per);
const s = p.toString();
navigate(`/orders${s ? `?${s}` : ''}`, { replace: true });
};
const list = useQuery({ ...trpc.bo.orders.list.queryOptions({ group, period, search: q || undefined }), refetchInterval: 20_000, placeholderData: keepPreviousData });
const counts = useQuery({ ...trpc.bo.orders.counts.queryOptions({ period, search: q || undefined }), refetchInterval: 20_000 });
const c = counts.data;
const countOf = (g: Group) => (!c ? null : g === 'active' ? c.new + c.pending + c.assigned + c.in_progress : g === 'all' ? null : c[g]);
// по дням: «Сегодня, 21 окт.»
const days = useMemo(() => {
const out: { key: string; label: string; rows: NonNullable<typeof list.data> }[] = [];
const dayKey = (d: Date) => formatDateTime(locale as Locale, d, { year: 'numeric', month: '2-digit', day: '2-digit' });
const today = dayKey(new Date());
const tomorrow = dayKey(new Date(Date.now() + 86_400_000));
for (const r of list.data ?? []) {
const k = dayKey(r.scheduledAt);
let d = out.at(-1);
if (!d || d.key !== k) {
const nice = formatDateTime(locale as Locale, r.scheduledAt, { weekday: 'short', day: 'numeric', month: 'long' });
const label = k === today ? `${t('ord.today')}, ${nice}` : k === tomorrow ? `${t('ord.tomorrow')}, ${nice}` : nice;
d = { key: k, label: label.charAt(0).toUpperCase() + label.slice(1), rows: [] };
out.push(d);
}
d.rows.push(r);
}
return out;
}, [list.data, locale, t]);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('ord.title')}</h1>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<AutoSwitch canEdit={isSuper} />
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('ord.new')}
</button>
</div>
</div>
<OrdersTabs me={me} current="orders" />
{!!c?.needsDispatcher && group !== 'new' && (
<button type="button" className="bo-alert" onClick={() => set({ group: 'new' })}>
<I name="warn" size={18} />
<span className="bo-grow">{t('ord.needsDispatcher', { n: c.needsDispatcher })}</span>
<I name="arrowDown" size={16} />
</button>
)}
<div className="bo-toolbar">
<label className="bo-search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} placeholder={t('ord.search')} aria-label={t('ord.search')} onChange={(e) => setSearch(e.target.value)} />
</label>
<div className="bo-pills" role="group" aria-label={t('ord.period')}>
{PERIODS.map((p) => (
<button key={p} type="button" className="bo-pill" aria-pressed={period === p} onClick={() => set({ period: p })}>
{t(`ord.period.${p}`)}
</button>
))}
</div>
</div>
<div className="bo-ogroups" role="tablist">
{GROUPS.map((g) => {
const n = countOf(g);
return (
<button key={g} type="button" role="tab" aria-selected={group === g} className="bo-ogroup" onClick={() => set({ group: g })}>
{g !== 'active' && <span className="bo-ogroup__dot" style={{ background: GROUP_DOT[g] }} />}
{t(`ord.group.${g}`)}
{n != null && <span className="bo-ogroup__n">{n}</span>}
</button>
);
})}
</div>
{list.isPending ? (
<div className="bo-col">
<Skeleton h={64} />
<Skeleton h={64} />
<Skeleton h={64} />
</div>
) : days.length === 0 ? (
<div className="bo-panel bo-empty">{t('ord.empty')}</div>
) : (
<div className="bo-col" style={{ gap: 18 }}>
{days.map((d) => (
<section key={d.key}>
<h2 className="bo-oday">
{d.label}
<span className="bo-faint">{d.rows.length}</span>
</h2>
<ul className="bo-orows">
{d.rows.map((o) => {
const end = new Date(o.scheduledAt.getTime() + o.durationMinutes * MIN);
return (
<li key={o.id}>
<a href={`#/orders/${o.id}`} className="bo-orow" data-group={o.group}>
<span className="bo-orow__time">
<b>{time(o.scheduledAt)}</b>
<span>{time(end)}</span>
</span>
<span className="bo-orow__main">
<span className="bo-orow__title">
<span className="bo-num bo-faint">№{o.number}</span> {shared(`services.${o.service}.name`)}
</span>
<span className="bo-orow__sub">
{[o.isOnline ? shared('common.online') : o.district ? shared(`districts.${o.district}`) : null, o.client.name].filter(Boolean).join(' · ')}
</span>
</span>
<span className="bo-orow__who">
{o.provider ? (
<span>{o.provider.name}</span>
) : o.pending ? (
<span className="bo-orow__pending">
{o.pending.auto ? <I name="bot" size={14} /> : null}
{t('ord.waitsAnswer', { name: o.pending.name })}
</span>
) : o.needsDispatcher ? (
<span className="bo-wait-chip bo-wait-chip--hot">{t('ord.needsHuman')}</span>
) : o.group === 'new' && o.scheduledAt.getTime() < Date.now() ? (
<span className="bo-wait-chip bo-wait-chip--hot">{t('ord.overdue')}</span>
) : o.group === 'new' ? (
<span className="bo-faint">{t('ord.searching')}</span>
) : (
<span className="bo-faint">—</span>
)}
</span>
<span className="bo-orow__status">
<span className="bo-status" style={{ ['--dot' as string]: GROUP_DOT[o.group] }}>
{statusLabel(shared, o.flow, o.status)}
</span>
{isSuper && o.total != null && <b className="bo-num">{amd(o.total)}</b>}
{o.group === 'done' && o.paymentStatus === 'UNPAID' && <span className="bo-chip bo-chip--warn">{shared('payment.UNPAID')}</span>}
</span>
</a>
</li>
);
})}
</ul>
</section>
))}
</div>
)}
{creating && <NewOrderDialog onClose={() => setCreating(false)} />}
</>
);
}
export function statusLabel(shared: (k: string) => string, flow: string, status: string) {
const k = `status.${flow}.${status}`;
const v = shared(k);
return v === k ? status : v;
}
/** Автораспределение: видно всем, переключает суперадмин */
function AutoSwitch({ canEdit }: { canEdit: boolean }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const s = useQuery(trpc.bo.orders.autoSetting.queryOptions());
const save = useMutation(
trpc.bo.orders.setAutoSetting.mutationOptions({
onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.orders.autoSetting.queryKey() }),
onError: (e) => toast(errText(t, e), 'error'),
}),
);
if (!s.data) return null;
const on = s.data.auto;
return (
<button
type="button"
className={`bo-autosw${on ? ' is-on' : ''}`}
role="switch"
aria-checked={on}
disabled={!canEdit || save.isPending}
title={t('ord.autoHint', { tries: s.data.maxTries, minutes: s.data.timeoutMinutes })}
onClick={() => save.mutate({ auto: !on })}
>
<span className="bo-autosw__knob" />
{t(on ? 'ord.autoOn' : 'ord.autoOff')}
</button>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-661
View File
@@ -1,661 +0,0 @@
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, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
import { statusLabel } from './Orders.tsx';
const ROLES = ['CLEANER', 'HANDYMAN', 'DRIVER', 'TEACHER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const;
type Role = (typeof ROLES)[number];
type Lang = (typeof LANGS)[number];
type Slot = { weekday: number; startMinute: number; endMinute: number };
const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const minutes = (s: string) => {
const [h, m] = s.split(':').map(Number);
return (h ?? 0) * 60 + (m ?? 0);
};
export function ProviderCard({ id, me }: { id: string; me: Me }) {
const { t, shared, date, dateTime, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const p = useQuery(trpc.bo.providers.get.queryOptions({ id }));
const dict = useQuery(trpc.bo.providers.dictionaries.queryOptions());
const [dlg, setDlg] = useState<'edit' | 'block' | 'payout' | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.providers.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.providers.list.queryKey() });
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const setActive = useMutation(trpc.bo.providers.setActive.mutationOptions({ onSuccess: () => (refresh(), toast(t('prv.saved'))), onError }));
const reset = useMutation(trpc.bo.providers.resetInvite.mutationOptions({ onSuccess: refresh, onError }));
const removeDoc = useMutation(trpc.bo.providers.removeDoc.mutationOptions({ onSuccess: refresh, onError }));
const chat = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError }));
const canChat = me.roles.some((r) => r === 'SUPERADMIN' || r === 'SUPPORT');
const back = (
<a className="bo-back" href="#/providers">
<I name="back" size={16} />
{t('prv.back')}
</a>
);
if (p.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, p.error)}</div>
</>
);
if (!p.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={300} />
</div>
);
const d = p.data;
const districtName = new Map((dict.data?.districts ?? []).map((x) => [x.id, shared(`districts.${x.code}`)]));
const q = d.quality;
const offers30 = q.accepted30 + q.declined30 + q.expired30;
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>{shared(`roles.${d.role}`)}</span>
{d.company && <span>{d.company.name}</span>}
{d.phone && <a href={`tel:${d.phone.replace(/\s/g, '')}`}>{d.phone}</a>}
{d.username && <span>@{d.username}</span>}
{d.active ? (
d.onShift ? <span className="bo-chip bo-chip--ok">{t('prv.onShift')}</span> : <span className="bo-chip">{t('prv.offShift')}</span>
) : (
<span className="bo-chip bo-chip--danger">{t('prv.blocked')}</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{canChat && d.linked && (
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => chat.mutate({ providerId: d.id })}>
<I name="chats" size={16} />
{t('prv.write')}
</button>
)}
<button type="button" className="bo-btn" onClick={() => setDlg('edit')}>
{t('prv.edit')}
</button>
</div>
</header>
{!d.active && (
<div className="bo-banner" style={{ background: 'var(--bc-pink)', color: 'var(--bc-plum)' }}>
<span>
<b>{t('prv.blockedSince', { date: d.blockedAt ? date(d.blockedAt) : '—' })}</b>
{d.blockedReason && ` ${d.blockedReason}`}
</span>
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setActive.mutate({ id: d.id, active: true })} disabled={setActive.isPending}>
{t('prv.unblock')}
</button>
</div>
)}
{!d.linked && d.inviteUrl && (
<div className="bo-banner">
<span>{t('prv.inviteHint')}</span>
<span style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="bo-btn bo-btn--sm"
onClick={() => {
void navigator.clipboard?.writeText(d.inviteUrl!);
toast(t('ui.copied'));
}}
>
{t('prv.copyInvite')}
</button>
</span>
</div>
)}
<div className="bo-client-grid">
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.quality')}</h2>
<span className="bo-faint bo-small">{t('prv.days30')}</span>
</div>
<div className="bo-quality">
<div>
<b className="bo-bignum">{q.rating != null ? `★ ${q.rating}` : '—'}</b>
<span className="bo-small bo-muted">{t('prv.ratings', { n: q.ratingCount })}</span>
</div>
<div>
<b className="bo-bignum">{offers30 ? `${Math.round((q.accepted30 / offers30) * 100)}%` : '—'}</b>
<span className="bo-small bo-muted">{t('prv.acceptRate', { a: q.accepted30, n: offers30 })}</span>
</div>
<div>
<b className="bo-bignum" style={{ color: q.declined30 + q.expired30 >= 3 ? 'var(--bc-danger)' : undefined }}>
{q.declined30 + q.expired30}
</b>
<span className="bo-small bo-muted">{t('prv.refusals', { d: q.declined30, e: q.expired30 })}</span>
</div>
<div>
<b className="bo-bignum" style={{ color: q.low30 ? 'var(--bc-danger)' : undefined }}>
{q.low30}
</b>
<span className="bo-small bo-muted">{t('prv.lowRatings')}</span>
</div>
</div>
{d.ratings.length > 0 && (
<ul className="bo-list" style={{ marginTop: 14 }}>
{d.ratings.slice(0, 8).map((r) => (
<li key={r.id} style={{ alignItems: 'flex-start' }}>
<span className="bo-stars" style={{ minWidth: 76 }} data-low={r.score <= 3 || undefined}>
{'★'.repeat(r.score)}
{'☆'.repeat(5 - r.score)}
</span>
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
{r.comment ?? <span className="bo-faint">{t('ord.noComment')}</span>}
<span className="bo-small bo-faint" style={{ display: 'block' }}>
<a href={`#/orders/${r.orderId}`}>№{r.orderNumber}</a> · {r.client} · {date(r.at)}
</span>
</span>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.upcoming')}</h2>
</div>
{d.upcoming.length === 0 ? (
<p className="bo-hint">{t('prv.noUpcoming')}</p>
) : (
<ul className="bo-list">
{d.upcoming.map((o) => (
<li key={o.id}>
<a href={`#/orders/${o.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>
№{o.number} · {shared(`services.${o.service}.name`)}
</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{dateTime(o.scheduledAt)}
{o.district && ` · ${shared(`districts.${o.district}`)}`}
</span>
</a>
<span className="bo-chip">{statusLabel(shared, o.flow, o.status)}</span>
</li>
))}
</ul>
)}
{d.recent.length > 0 && (
<p className="bo-hint" style={{ marginTop: 12 }}>
{t('prv.recent')}:{' '}
{d.recent.slice(0, 8).map((o, i) => (
<span key={o.id}>
{i > 0 && ', '}
<a href={`#/orders/${o.id}`}>№{o.number}</a>
{o.rating && ` ★${o.rating}`}
</span>
))}
</p>
)}
</section>
</div>
<div className="bo-col">
{d.money && (
<section className="bo-panel bo-money-card">
<div className="bo-panel__head" style={{ marginBottom: 6 }}>
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
{t('prv.balance')}
</span>
<span className="bo-small">{rateText(t, d.money.rate)}</span>
</div>
<div className="bo-bignum">{amd(d.money.balance)}</div>
<div className="bo-small" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', margin: '8px 0 12px' }}>
<span>
{t('prv.thisMonth')} <b className="bo-num">{amd(d.money.thisMonth)}</b>
</span>
<span>
{t('prv.prevMonth')} <b className="bo-num">{amd(d.money.prevMonth)}</b>
</span>
<span>
{t('prv.paidTotal')} <b className="bo-num">{amd(d.money.paidTotal)}</b>
</span>
</div>
<button type="button" className="bo-btn bo-btn--sm" disabled={d.money.balance <= 0} onClick={() => setDlg('payout')}>
{t('prv.payout')}
</button>
{d.money.payouts.length > 0 && (
<ul className="bo-list" style={{ marginTop: 12 }}>
{d.money.payouts.slice(0, 6).map((x) => (
<li key={x.id}>
<span className="bo-grow">
<b className="bo-num">{amd(x.amount)}</b>
<span className="bo-small" style={{ display: 'block', opacity: 0.75 }}>
{[x.paidAt && date(x.paidAt), x.by, x.note].filter(Boolean).join(' · ')}
</span>
</span>
</li>
))}
</ul>
)}
</section>
)}
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.work')}</h2>
</div>
<dl className="bo-kv">
<dt>{t('prv.languages')}</dt>
<dd>{d.languages.join(', ').toUpperCase()}</dd>
<dt>{t('prv.districts')}</dt>
<dd>{d.districtIds.length ? d.districtIds.map((x) => districtName.get(x) ?? '').join(', ') : t('prv.anyDistrict')}</dd>
</dl>
<WeekView schedule={d.schedule} />
{d.adminNote && <p className="bo-side-panel__note" style={{ marginTop: 12 }}>{d.adminNote}</p>}
</section>
<DocsPanel providerId={d.id} docs={d.docs} onChanged={refresh} onRemove={(docId) => confirm(t('prv.removeDocConfirm')) && removeDoc.mutate({ id: docId })} />
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.access')}</h2>
</div>
<div className="bo-col" style={{ gap: 8 }}>
{d.active ? (
<button type="button" className="bo-btn bo-btn--danger bo-btn--sm" onClick={() => setDlg('block')}>
{t('prv.block')}
</button>
) : (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setActive.mutate({ id: d.id, active: true })}>
{t('prv.unblock')}
</button>
)}
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => confirm(t('prv.resetConfirm')) && reset.mutate({ id: d.id })} disabled={reset.isPending}>
{t('prv.resetInvite')}
</button>
<p className="bo-hint">{t('prv.since', { date: date(d.createdAt) })}</p>
</div>
</section>
</div>
</div>
{dlg === 'edit' && <ProviderForm me={me} provider={d} onClose={() => setDlg(null)} />}
{dlg === 'block' && <BlockDialog id={d.id} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'payout' && d.money && <PayoutDialog id={d.id} balance={d.money.balance} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
function rateText(t: ReturnType<typeof useT>['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number | null; name?: string }) {
if (r.kind === 'preset') return t('prv.ratePreset', { name: r.name ?? '' });
// сотрудник партнёра «как Uber» — зарплату ему платит партнёр
if (r.kind === 'company' && r.value == null) return t('prv.ratePartnerPays');
const v = r.type === 'PERCENT' ? `${r.value}%` : `${r.value} ֏`;
return r.kind === 'company' ? t('prv.rateCompany', { v }) : t('prv.rateOwn', { v });
}
function WeekView({ schedule }: { schedule: Slot[] }) {
const { shared } = useT();
return (
<div className="bo-week">
{[0, 1, 2, 3, 4, 5, 6].map((wd) => {
const s = schedule.filter((x) => x.weekday === wd);
return (
<div key={wd} className="bo-week__day" data-off={!s.length || undefined}>
<b>{shared(`admin.weekdays.${wd}`)}</b>
<span>{s.length ? s.map((x) => `${hhmm(x.startMinute)}–${hhmm(x.endMinute)}`).join(', ') : '—'}</span>
</div>
);
})}
</div>
);
}
function DocsPanel({ providerId, docs, onChanged, onRemove }: { providerId: string; docs: { id: string; title: string; fileName: string; mime: string; size: number; createdAt: Date }[]; onChanged: () => void; onRemove: (id: string) => void }) {
const { t, date } = useT();
const toast = useToast();
const file = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState('');
const [busy, setBusy] = useState(false);
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('title', title || f.name);
form.set('file', f);
const res = await fetch(`/files/bo/provider-doc/${providerId}`, { method: 'POST', body: form, credentials: 'include' });
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? String(res.status));
setTitle('');
onChanged();
toast(t('prv.docAdded'));
} catch (e) {
toast(errText(t, e), 'error');
} finally {
setBusy(false);
}
};
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.docs')}</h2>
<span className="bo-faint bo-small">{docs.length}</span>
</div>
{docs.length === 0 ? (
<p className="bo-hint">{t('prv.noDocsHint')}</p>
) : (
<ul className="bo-list">
{docs.map((d) => (
<li key={d.id}>
<I name="file" size={20} />
<a className="bo-grow" href={`/files/bo/provider-doc-file/${d.id}`} target="_blank" rel="noreferrer" style={{ color: 'inherit', minWidth: 0 }}>
<b>{d.title}</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{d.fileName} · {date(d.createdAt)}
</span>
</a>
<button type="button" className="bo-iconbtn" onClick={() => onRemove(d.id)} aria-label={t('chat.tplDelete')}>
<I name="close" size={16} />
</button>
</li>
))}
</ul>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input className="bo-input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t('prv.docTitlePh')} maxLength={120} style={{ minHeight: 36, padding: '6px 12px' }} />
<button type="button" className="bo-btn bo-btn--sm" onClick={() => file.current?.click()} disabled={busy}>
<I name="clip" size={16} />
{t('prv.upload')}
</button>
<input
ref={file}
type="file"
hidden
accept="image/*,application/pdf"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void upload(f);
}}
/>
</div>
</section>
);
}
function BlockDialog({ id, onClose, onDone }: { id: string; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [reason, setReason] = useState('');
const save = useMutation(
trpc.bo.providers.setActive.mutationOptions({
onSuccess: () => {
onDone();
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('prv.blockTitle')}
footer={
<>
<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={!reason.trim() || save.isPending} onClick={() => save.mutate({ id, active: false, reason })}>
{t('prv.block')}
</button>
</>
}
>
<Field label={t('prv.blockReason')}>
<textarea className="bo-textarea" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('prv.blockReasonPh')} />
</Field>
<p className="bo-hint">{t('prv.blockHint')}</p>
</Dialog>
);
}
function PayoutDialog({ 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.providers.payout.mutationOptions({
onSuccess: () => {
onDone();
toast(t('prv.payoutDone'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('prv.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('prv.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>
);
}
// ───── форма исполнителя ─────
type ProviderData = {
id: string;
name: string;
phone: string | null;
role: string;
company: { id: string } | null;
languages: string[];
districtIds: string[];
schedule: Slot[];
adminNote: string | null;
money: { rateType: string; rateValue: number; payoutPresetId: string | null; presets: { id: string; name: string }[] } | null;
};
export function ProviderForm({ me, provider, onClose }: { me: Me; provider?: ProviderData; onClose: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const dict = useQuery(trpc.bo.providers.dictionaries.queryOptions());
const isSuper = me.roles.includes('SUPERADMIN');
const [f, setF] = useState({
name: provider?.name ?? '',
phone: provider?.phone ?? '',
role: (provider?.role ?? 'CLEANER') as Role,
companyId: provider?.company?.id ?? '',
languages: (provider?.languages ?? ['ru']) as Lang[],
districtIds: provider?.districtIds ?? [],
adminNote: provider?.adminNote ?? '',
rateType: provider?.money?.rateType ?? 'PERCENT',
rateValue: String(provider?.money?.rateValue ?? 70),
payoutPresetId: provider?.money?.payoutPresetId ?? '',
});
// график: одно окно на день (так заведено в рабочем боте); дни без окна — выходные
const [week, setWeek] = useState<(null | { from: string; to: string })[]>(() =>
[0, 1, 2, 3, 4, 5, 6].map((wd) => {
const s = provider ? provider.schedule.find((x) => x.weekday === wd) : wd < 6 ? { startMinute: 540, endMinute: 1200 } : null;
return s ? { from: hhmm(s.startMinute), to: hhmm(s.endMinute) } : null;
}),
);
const done = (r?: { id: string; inviteUrl: string } | void) => {
qc.invalidateQueries({ queryKey: trpc.bo.providers.list.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.providers.get.queryKey() });
toast(t('prv.saved'));
onClose();
if (r && 'id' in r) navigate(`/providers/${r.id}`);
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const create = useMutation(trpc.bo.providers.create.mutationOptions({ onSuccess: done, onError }));
const update = useMutation(trpc.bo.providers.update.mutationOptions({ onSuccess: () => done(), onError }));
const companies = (dict.data?.companies ?? []).filter((c) => c.roles.includes(f.role));
const payload = {
name: f.name,
phone: f.phone || null,
role: f.role,
companyId: f.companyId || null,
languages: f.languages,
districtIds: f.districtIds,
adminNote: f.adminNote || null,
schedule: week.flatMap((w, wd) => (w ? [{ weekday: wd, startMinute: minutes(w.from), endMinute: minutes(w.to) }] : [])),
rate: isSuper ? { rateType: f.rateType as 'FIXED' | 'PERCENT', rateValue: Number(f.rateValue) || 0, payoutPresetId: f.payoutPresetId || null } : undefined,
};
const toggle = <T,>(list: T[], v: T) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
return (
<Dialog
open
wide
onClose={onClose}
title={provider ? t('prv.editTitle') : t('prv.addTitle')}
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.languages.length || create.isPending || update.isPending}
onClick={() => (provider ? update.mutate({ id: provider.id, ...payload }) : create.mutate(payload))}
>
{t('ui.save')}
</button>
</>
}
>
<div className="bo-row2">
<Field label={t('prv.name')}>
<input className="bo-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
</Field>
<Field label={t('chat.phone')}>
<input className="bo-input" value={f.phone} maxLength={30} onChange={(e) => setF({ ...f, phone: e.target.value })} placeholder="+374 …" />
</Field>
</div>
<div className="bo-row2">
<Field label={t('prv.role')}>
<select className="bo-select" value={f.role} onChange={(e) => setF({ ...f, role: e.target.value as Role, companyId: '' })}>
{ROLES.map((r) => (
<option key={r} value={r}>
{shared(`roles.${r}`)}
</option>
))}
</select>
</Field>
<Field label={t('prv.company')}>
<select className="bo-select" value={f.companyId} onChange={(e) => setF({ ...f, companyId: e.target.value })}>
<option value="">{t('prv.freelance')}</option>
{companies.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</Field>
</div>
<div>
<span className="bo-label">{t('prv.languages')}</span>
<div className="bo-pills" style={{ marginTop: 6 }}>
{LANGS.map((l) => (
<button key={l} type="button" className="bo-pill" aria-pressed={f.languages.includes(l)} onClick={() => setF({ ...f, languages: toggle(f.languages, l) })}>
{l.toUpperCase()}
</button>
))}
</div>
</div>
<div>
<span className="bo-label">{t('prv.districts')}</span>
<div className="bo-pills" style={{ marginTop: 6 }}>
{(dict.data?.districts ?? []).map((d) => (
<button key={d.id} type="button" className="bo-pill" aria-pressed={f.districtIds.includes(d.id)} onClick={() => setF({ ...f, districtIds: toggle(f.districtIds, d.id) })}>
{shared(`districts.${d.code}`)}
</button>
))}
</div>
<span className="bo-hint">{t('prv.districtsHint')}</span>
</div>
<div>
<span className="bo-label">{t('prv.schedule')}</span>
<div className="bo-sched">
{week.map((w, wd) => (
<div key={wd} className="bo-sched__row" data-off={!w || undefined}>
<label className="bo-check" style={{ minWidth: 64 }}>
<input type="checkbox" checked={!!w} onChange={(e) => setWeek(week.map((x, i) => (i === wd ? (e.target.checked ? { from: '09:00', to: '20:00' } : null) : x)))} />
{shared(`admin.weekdays.${wd}`)}
</label>
{w && (
<>
<input className="bo-input" type="time" value={w.from} step={900} onChange={(e) => setWeek(week.map((x, i) => (i === wd ? { ...w, from: e.target.value } : x)))} />
<span>–</span>
<input className="bo-input" type="time" value={w.to} step={900} onChange={(e) => setWeek(week.map((x, i) => (i === wd ? { ...w, to: e.target.value } : x)))} />
</>
)}
</div>
))}
</div>
</div>
{isSuper && !f.companyId && (
<div className="bo-row2">
<Field label={t('prv.rate')}>
<select className="bo-select" value={f.payoutPresetId || f.rateType} onChange={(e) => (e.target.value === 'PERCENT' || e.target.value === 'FIXED' ? setF({ ...f, payoutPresetId: '', rateType: e.target.value }) : setF({ ...f, payoutPresetId: e.target.value }))}>
<option value="PERCENT">{shared('admin.rate.PERCENT')}</option>
<option value="FIXED">{shared('admin.rate.FIXED')}</option>
{(provider?.money?.presets ?? []).map((p) => (
<option key={p.id} value={p.id}>
{t('prv.ratePreset', { name: p.name })}
</option>
))}
</select>
</Field>
{!f.payoutPresetId && (
<Field label={f.rateType === 'PERCENT' ? '%' : '֏'}>
<input className="bo-input" inputMode="numeric" value={f.rateValue} onChange={(e) => setF({ ...f, rateValue: e.target.value.replace(/\D/g, '') })} />
</Field>
)}
</div>
)}
<Field label={t('prv.note')}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={f.adminNote} maxLength={4000} onChange={(e) => setF({ ...f, adminNote: e.target.value })} placeholder={t('card.notePh')} />
</Field>
</Dialog>
);
}
-144
View File
@@ -1,144 +0,0 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { useT } from '../i18n.tsx';
import { Avatar, I, Skeleton } from '../components/kit.tsx';
import { ProviderForm } from './ProviderCard.tsx';
type Status = 'active' | 'blocked' | 'all';
const ROLES = ['CLEANER', 'HANDYMAN', 'DRIVER', 'TEACHER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
export function Providers({ me }: { me: Me }) {
const { t, shared, dateTime } = useT();
const trpc = useTRPC();
const [status, setStatus] = useState<Status>('active');
const [role, setRole] = useState<(typeof ROLES)[number] | undefined>();
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const list = useQuery({ ...trpc.bo.providers.list.queryOptions({ status, role, search: q || undefined }), placeholderData: keepPreviousData });
const roles = ROLES.filter((r) => role === r || (list.data ?? []).some((p) => p.role === r));
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('prv.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('prv.add')}
</button>
</div>
<div className="bo-toolbar">
<label className="bo-search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} placeholder={t('prv.search')} aria-label={t('prv.search')} onChange={(e) => setSearch(e.target.value)} />
</label>
<div className="bo-pills" role="group">
{(['active', 'blocked', 'all'] as const).map((s) => (
<button key={s} type="button" className="bo-pill" aria-pressed={status === s} onClick={() => setStatus(s)}>
{t(`prv.status.${s}`)}
</button>
))}
</div>
{roles.length > 1 && (
<div className="bo-pills" role="group">
{roles.map((r) => (
<button key={r} type="button" className="bo-pill" aria-pressed={role === r} onClick={() => setRole(role === r ? undefined : r)}>
{shared(`roles.${r}`)}
</button>
))}
</div>
)}
</div>
{!list.data ? (
<div className="bo-col">
<Skeleton h={72} />
<Skeleton h={72} />
</div>
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('prv.empty')}</div>
) : (
<div className="bo-panel" style={{ padding: 8 }}>
<table className="bo-table bo-ptable">
<thead>
<tr>
<th>{t('prv.col.name')}</th>
<th className="bo-r">{t('prv.col.rating')}</th>
<th className="bo-r">{t('prv.col.done')}</th>
<th className="bo-r">{t('prv.col.refused')}</th>
<th>{t('prv.col.next')}</th>
</tr>
</thead>
<tbody>
{list.data.map((p) => (
<tr key={p.id} onClick={() => (location.hash = `#/providers/${p.id}`)} data-off={!p.active || undefined}>
<td>
<a href={`#/providers/${p.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
<span className="bo-pavatar" data-shift={p.onShift || undefined}>
<Avatar name={p.name} id={p.id} size={38} />
</span>
<span style={{ minWidth: 0 }}>
<span className="bo-who__name">{p.name}</span>
<span className="bo-who__meta">
{[shared(`roles.${p.role}`), p.company?.name, p.languages.join(' ').toUpperCase()].filter(Boolean).join(' · ')}
</span>
<span className="bo-tags" style={{ marginTop: 4 }}>
{!p.active && <span className="bo-chip bo-chip--danger">{t('prv.blocked')}</span>}
{p.active && p.onShift && <span className="bo-chip bo-chip--ok">{t('prv.onShift')}</span>}
{!p.linked && <span className="bo-chip bo-chip--warn">{t('prv.notLinked')}</span>}
{p.docs === 0 && p.active && <span className="bo-chip">{t('prv.noDocs')}</span>}
</span>
</span>
</a>
</td>
<td className="bo-r">
{p.rating != null ? (
<>
<b className="bo-num">★ {p.rating}</b>
<span className="bo-faint bo-small" style={{ display: 'block' }}>
{t('prv.ratings', { n: p.ratingCount })}
{p.low30 > 0 && <span style={{ color: 'var(--bc-danger)' }}> · {t('prv.low', { n: p.low30 })}</span>}
</span>
</>
) : (
<span className="bo-faint">—</span>
)}
</td>
<td className="bo-r bo-num">{p.done30}</td>
<td className="bo-r bo-num" style={{ color: p.refused30 >= 3 ? 'var(--bc-danger)' : undefined }}>
{p.refused30}
</td>
<td>
{p.next ? (
<a href={`#/orders/${p.next.id}`} onClick={(e) => e.stopPropagation()} className="bo-small">
№{p.next.number} · {dateTime(p.next.scheduledAt)}
</a>
) : (
<span className="bo-faint">—</span>
)}
{p.today > 0 && (
<span className="bo-faint bo-small" style={{ display: 'block' }}>
{t('prv.today', { n: p.today })}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="bo-hint" style={{ padding: '10px 12px 4px' }}>
{t('prv.period30')}
</p>
</div>
)}
{creating && <ProviderForm me={me} onClose={() => setCreating(false)} />}
</>
);
}
@@ -1,485 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDateTime, type Locale } from '@barev/i18n';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
import { CopyLink } from './Agencies.tsx';
type Deal = 'PROFIT_SHARE' | 'COMMISSION';
type Terms = { deal: Deal; commissionPct: number; profitSharePct: number; fotPct: number; taxPct: number };
function useTermsText() {
const { t } = useT();
return (x: Terms) => (x.deal === 'COMMISSION' ? t('sp.terms.COMMISSION', { pct: x.commissionPct }) : t('sp.terms.PROFIT_SHARE', { pct: x.profitSharePct, fot: x.fotPct, tax: x.taxPct }));
}
/** Партнёры услуг: клининговые компании со своими сотрудниками и менеджером */
export function ServicePartners() {
const { t, amd } = useT();
const trpc = useTRPC();
const terms = useTermsText();
const list = useQuery(trpc.bo.partners.servicePartners.queryOptions());
const [creating, setCreating] = useState(false);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('sp.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('sp.add')}
</button>
</div>
<p className="bo-hint" style={{ marginTop: -6, marginBottom: 14 }}>
{t('sp.lead')}
</p>
{!list.data ? (
<Skeleton h={240} />
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('sp.empty')}</div>
) : (
<div className="bo-panel" style={{ padding: 8 }}>
<table className="bo-table bo-ptable">
<thead>
<tr>
<th>{t('sp.col.name')}</th>
<th className="bo-r">{t('sp.col.staff')}</th>
<th className="bo-r">{t('sp.col.active')}</th>
<th className="bo-r">{t('sp.col.month')}</th>
<th className="bo-r">{t('sp.col.balance')}</th>
</tr>
</thead>
<tbody>
{list.data.map((c) => (
<tr key={c.id} onClick={() => navigate(`/service-partners/${c.id}`)} data-off={!c.active || undefined}>
<td>
<a href={`#/service-partners/${c.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
<Avatar name={c.name} id={c.id} size={38} />
<span style={{ minWidth: 0 }}>
<span className="bo-who__name">{c.name}</span>
<span className="bo-who__meta">{terms(c.terms)}</span>
<span className="bo-tags" style={{ marginTop: 4 }}>
{!c.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
{c.managers === 0 && <span className="bo-chip bo-chip--warn">{t('sp.noManager')}</span>}
{c.autoAssign && <span className="bo-chip">{t('sp.auto')}</span>}
</span>
</span>
</a>
</td>
<td className="bo-r bo-num">{c.staff}</td>
<td className="bo-r bo-num">{c.activeOrders}</td>
<td className="bo-r bo-num">{amd(c.thisMonth)}</td>
<td className="bo-r">
<b className="bo-num">{amd(c.balance)}</b>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{creating && <PartnerDialog onClose={() => setCreating(false)} />}
</>
);
}
export function ServicePartnerCard({ id }: { id: string }) {
const { t, amd, date, dateTime, locale, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const terms = useTermsText();
const c = useQuery(trpc.bo.partners.servicePartner.queryOptions({ id }));
const [dlg, setDlg] = useState<'edit' | 'payout' | null>(null);
const refresh = () => {
void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartner.queryKey({ id }) });
void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartners.queryKey() });
};
const reset = useMutation(trpc.bo.partners.resetManagerLink.mutationOptions({ onSuccess: () => (refresh(), toast(t('sp.linkReset'))), onError: (e) => toast(errText(t, e), 'error') }));
const removeManager = useMutation(trpc.bo.partners.removeManager.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
const back = (
<a className="bo-back" href="#/service-partners">
<I name="back" size={16} />
{t('sp.back')}
</a>
);
if (!c.data)
return (
<>
{back}
{c.isError ? <div className="bo-panel bo-empty">{errText(t, c.error)}</div> : <Skeleton h={300} />}
</>
);
const d = c.data;
return (
<>
{back}
<header className="bo-profile">
<Avatar name={d.name} id={d.id} size={64} />
<div style={{ flex: 1, minWidth: 220 }}>
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
{d.name}
</h1>
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
<span>{terms(d.terms)}</span>
{d.contactPhone && <span>{d.contactPhone}</span>}
<span>{t('prv.since', { date: date(d.createdAt) })}</span>
{d.autoAssign && <span className="bo-chip">{t('sp.auto')}</span>}
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
</div>
</div>
<button type="button" className="bo-btn" onClick={() => setDlg('edit')}>
{t('prv.edit')}
</button>
</header>
<div className="bo-client-grid">
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.managerLink')}</h2>
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" disabled={reset.isPending} onClick={() => confirm(t('sp.resetConfirm')) && reset.mutate({ id })}>
{t('sp.resetLink')}
</button>
</div>
<p className="bo-hint" style={{ marginBottom: 10 }}>
{t('sp.managerLinkHint')}
</p>
<CopyLink url={d.managerLink} />
<h3 className="bo-label" style={{ marginTop: 16 }}>
{t('sp.managers')}
</h3>
{d.managers.length === 0 ? (
<p className="bo-hint">{t('sp.noManagers')}</p>
) : (
<ul className="bo-list">
{d.managers.map((m) => (
<li key={m.id}>
<span className="bo-grow">
<b>{m.name}</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{t('sp.managerSince', { date: date(m.since) })}
</span>
</span>
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => confirm(t('sp.removeManagerConfirm', { name: m.name })) && removeManager.mutate({ id: m.id })}>
{t('sp.remove')}
</button>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.staff')}</h2>
<span className="bo-faint bo-small">{t('sp.staffHint')}</span>
</div>
{d.staff.length === 0 ? (
<p className="bo-hint">{t('sp.noStaff')}</p>
) : (
<ul className="bo-list">
{d.staff.map((s) => (
<li key={s.id} style={{ opacity: s.active ? 1 : 0.5 }}>
<a href={`#/providers/${s.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>{s.name}</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{[s.phone, s.maxOrdersPerDay ? t('sp.limitOrders', { n: s.maxOrdersPerDay }) : null, s.maxHoursPerDay ? t('sp.limitHours', { n: s.maxHoursPerDay }) : null].filter(Boolean).join(' · ') || '—'}
</span>
</a>
{!s.linked && <span className="bo-chip bo-chip--warn">{t('sp.notLinked')}</span>}
{!s.active && <span className="bo-chip">{t('sp.off')}</span>}
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.orders')}</h2>
</div>
{d.orders.length === 0 ? (
<p className="bo-hint">{t('sp.noOrders')}</p>
) : (
<ul className="bo-list">
{d.orders.map((o) => (
<li key={o.id}>
<a href={`#/orders/${o.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>
#{o.number} · {shared(`services.${o.service}.name`)}
</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{dateTime(o.scheduledAt)} · {o.staff ?? t('sp.waitingStaff')}
</span>
</a>
</li>
))}
</ul>
)}
</section>
</div>
<div className="bo-col">
<section className="bo-panel bo-money-card">
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
{t('sp.toPay')}
</span>
<div className="bo-bignum" style={{ margin: '6px 0 8px' }}>
{amd(d.money.balance)}
</div>
<div className="bo-small" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 12 }}>
<span>
{t('ag.accrued')} <b className="bo-num">{amd(d.money.accrued)}</b>
</span>
<span>
{t('prv.paidTotal')} <b className="bo-num">{amd(d.money.paid)}</b>
</span>
</div>
<button type="button" className="bo-btn bo-btn--sm" disabled={d.money.balance <= 0} onClick={() => setDlg('payout')}>
{t('prv.payout')}
</button>
{d.money.months.length > 0 && (
<ul className="bo-list" style={{ marginTop: 12 }}>
{d.money.months.map((m) => (
<li key={m.period}>
<span className="bo-grow" style={{ textTransform: 'capitalize' }}>
{formatDateTime(locale as Locale, new Date(`${m.period}-15T12:00:00+04:00`), { month: 'long', year: 'numeric' })}
</span>
<span className="bo-small" style={{ opacity: 0.75 }}>
{t('ag.orders', { n: m.orders })} · {amd(m.revenue)}
</span>
<b className="bo-num">{amd(m.amount)}</b>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.byOrder')}</h2>
</div>
{d.money.recent.length === 0 ? (
<p className="bo-hint">{t('sp.noIncome')}</p>
) : (
<ul className="bo-list">
{d.money.recent.slice(0, 20).map((e) => (
<li key={e.orderId}>
<a href={`#/orders/${e.orderId}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>#{e.number}</b> <span className="bo-small bo-faint">{date(e.at)}</span>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{t('sp.paid', { sum: amd(e.paid) })}
{e.deal === 'PROFIT_SHARE' ? ` · ${t('sp.fotTax', { fot: amd(e.fot), tax: amd(e.tax) })}` : ` · −${e.pct}%`}
</span>
</a>
<b className="bo-num">{amd(e.amount)}</b>
</li>
))}
</ul>
)}
</section>
{d.note && (
<section className="bo-panel">
<p className="bo-side-panel__note" style={{ margin: 0 }}>
{d.note}
</p>
</section>
)}
</div>
</div>
{dlg === 'edit' && <PartnerDialog partner={d} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'payout' && <PartnerPayout id={d.id} balance={d.money.balance} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
type PartnerRow = {
id: string;
name: string;
contactPhone: string | null;
note: string | null;
active: boolean;
deal: Deal;
commissionPct: number;
profitSharePct: number;
fotPct: number | null;
taxPct: number | null;
defaults: { fotPct: number; taxPct: number };
};
function PartnerDialog({ partner, onClose, onDone }: { partner?: PartnerRow; onClose: () => void; onDone?: () => void }) {
const { t, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const settings = useQuery({ ...trpc.bo.settings.get.queryOptions(), enabled: !partner });
const defaults = partner?.defaults ?? { fotPct: settings.data?.['partner.fotPct'] ?? 50, taxPct: settings.data?.['partner.taxPct'] ?? 10 };
const [f, setF] = useState({
name: partner?.name ?? '',
contactPhone: partner?.contactPhone ?? '',
note: partner?.note ?? '',
active: partner?.active ?? true,
deal: partner?.deal ?? ('COMMISSION' as Deal),
commissionPct: String(partner?.commissionPct ?? 30),
profitSharePct: String(partner?.profitSharePct ?? 50),
fotPct: partner?.fotPct == null ? '' : String(partner.fotPct),
taxPct: partner?.taxPct == null ? '' : String(partner.taxPct),
});
const num = (s: string) => Math.min(100, Number(s) || 0);
const opt = (s: string) => (s === '' ? null : num(s));
// пример на 20 000 ֏, чтобы было видно, как считается
const ex = 20_000;
const fot = opt(f.fotPct) ?? defaults.fotPct;
const tax = opt(f.taxPct) ?? defaults.taxPct;
const example =
f.deal === 'COMMISSION'
? t('sp.exampleCOMMISSION', { sum: amd(ex), ours: amd(Math.floor((ex * num(f.commissionPct)) / 100)), partner: amd(ex - Math.floor((ex * num(f.commissionPct)) / 100)) })
: (() => {
const fotS = Math.floor((ex * fot) / 100);
const taxS = Math.floor((ex * tax) / 100);
const profit = Math.max(0, ex - fotS - taxS);
const p = Math.floor((profit * num(f.profitSharePct)) / 100);
return t('sp.examplePROFIT_SHARE', { sum: amd(ex), fot: amd(fotS), tax: amd(taxS), profit: amd(profit), partner: amd(p), ours: amd(profit - p) });
})();
const save = useMutation(
trpc.bo.partners.saveServicePartner.mutationOptions({
onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartners.queryKey() });
onDone?.();
onClose();
if (!partner) navigate(`/service-partners/${r.id}`);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={partner ? t('sp.editTitle') : t('sp.add')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn"
disabled={!f.name.trim() || save.isPending}
onClick={() =>
save.mutate({
id: partner?.id,
name: f.name,
contactPhone: f.contactPhone || null,
note: f.note || null,
active: f.active,
deal: f.deal,
commissionPct: num(f.commissionPct),
profitSharePct: num(f.profitSharePct),
fotPct: opt(f.fotPct),
taxPct: opt(f.taxPct),
})
}
>
{t('ui.save')}
</button>
</>
}
>
<div className="bo-row2">
<Field label={t('crm.name')}>
<input className="bo-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
</Field>
<Field label={t('sp.phone')}>
<input className="bo-input" value={f.contactPhone} maxLength={30} onChange={(e) => setF({ ...f, contactPhone: e.target.value })} />
</Field>
</div>
<Field label={t('sp.service')}>
<input className="bo-input" value={t('sp.cleaningOnly')} disabled />
</Field>
<Field label={t('sp.deal')}>
<div className="bo-chtabs">
{(['COMMISSION', 'PROFIT_SHARE'] as const).map((d) => (
<button key={d} type="button" aria-pressed={f.deal === d} onClick={() => setF({ ...f, deal: d })}>
{t(`sp.deal.${d}`)}
</button>
))}
</div>
</Field>
<p className="bo-hint" style={{ marginTop: -4 }}>
{t(`sp.dealHint.${f.deal}`)}
</p>
{f.deal === 'COMMISSION' ? (
<Field label={t('sp.commissionPct')}>
<input className="bo-input" inputMode="numeric" value={f.commissionPct} onChange={(e) => setF({ ...f, commissionPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
) : (
<>
<Field label={t('sp.profitSharePct')}>
<input className="bo-input" inputMode="numeric" value={f.profitSharePct} onChange={(e) => setF({ ...f, profitSharePct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
<div className="bo-row2">
<Field label={t('sp.fotPct')} hint={t('sp.defaultHint', { n: defaults.fotPct })}>
<input className="bo-input" inputMode="numeric" placeholder={String(defaults.fotPct)} value={f.fotPct} onChange={(e) => setF({ ...f, fotPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
<Field label={t('sp.taxPct')} hint={t('sp.defaultHint', { n: defaults.taxPct })}>
<input className="bo-input" inputMode="numeric" placeholder={String(defaults.taxPct)} value={f.taxPct} onChange={(e) => setF({ ...f, taxPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
</div>
</>
)}
<p className="bo-side-panel__note" style={{ margin: '0 0 12px' }}>
{example}
</p>
<Field label={t('crm.note')}>
<textarea className="bo-textarea" style={{ minHeight: 56 }} value={f.note} maxLength={2000} onChange={(e) => setF({ ...f, note: e.target.value })} />
</Field>
<label className="bo-check">
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
{t('sp.activeLabel')}
</label>
</Dialog>
);
}
function PartnerPayout({ id, balance, onClose, onDone }: { id: string; balance: number; onClose: () => void; onDone: () => void }) {
const { t, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const [amount, setAmount] = useState(String(balance));
const [note, setNote] = useState('');
const save = useMutation(
trpc.bo.partners.servicePartnerPayout.mutationOptions({
onSuccess: () => {
onDone();
toast(t('prv.payoutDone'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('sp.payoutTitle')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!Number(amount) || save.isPending} onClick={() => save.mutate({ id, amount: Number(amount), note: note || undefined })}>
{t('prv.payoutConfirm')}
</button>
</>
}
>
<Field label={t('prv.amount')} hint={t('ag.balanceHint', { sum: amd(balance) })}>
<input className="bo-input" inputMode="numeric" value={amount} onChange={(e) => setAmount(e.target.value.replace(/\D/g, ''))} />
</Field>
<Field label={t('prv.payoutNote')}>
<input className="bo-input" value={note} maxLength={300} onChange={(e) => setNote(e.target.value)} placeholder={t('prv.payoutNotePh')} />
</Field>
</Dialog>
);
}
-482
View File
@@ -1,482 +0,0 @@
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>
);
}
+1 -3
View File
@@ -2,14 +2,12 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// Новая админка. API проксируется — cookie-сессия на том же origin.
// BO_API — другой локальный API (например, второй экземпляр на соседнем порту)
const api = process.env.BO_API ?? 'http://localhost:4000';
export default defineConfig({
plugins: [react()],
server: {
host: true,
allowedHosts: true,
proxy: { '/trpc': api, '/files': api },
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000' },
},
build: { target: 'es2022', sourcemap: true },
});
+17 -235
View File
@@ -5,27 +5,7 @@
import { getDb } from '@barev/db';
import { createT, formatDateTime, isLocale, LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
import { decodeStartPayload } from '@barev/core';
import {
startTextOverrides,
addClientMessage,
applyAgencyCode,
DomainError,
getClientByTelegramId,
giveConsent,
rateOrder,
linkWebRequest,
clientPoints,
pointsHistory,
completeWebRequest,
webRequestSlots,
type LinkResult,
setClientChannel,
setLocale,
startClient,
type ChatChannel,
type ChatFile,
type Ctx,
} from '@barev/services';
import { addClientMessage, applyAgencyCode, DomainError, getClientByTelegramId, giveConsent, linkWebRequest, setLocale, startClient, type Ctx } from '@barev/services';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier } from '@barev/services/queue';
import { Bot, InlineKeyboard, type Context } from 'grammy';
@@ -39,17 +19,12 @@ if (!token) console.log('CLIENT_BOT_TOKEN не задан — клиентски
const db = getDb();
const notifier = createQueueNotifier(process.env.REDIS_URL ?? 'redis://localhost:6379');
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
startTextOverrides(db);
const APP_URL = process.env.CLIENT_MINIAPP_URL ?? '';
const LANDING = process.env.LANDING_URL ?? '';
const bot = new Bot(token || 'disabled:token');
/** Ожидаем ввод кода агентства от этих чатов */
const awaitingCode = new Set<number>();
/** Заявка с сайта без адреса: следующий текст — адрес (chatId → заявка) */
const awaitingAddress = new Map<number, string>();
/** Низкая оценка: следующий текст — комментарий к ней (chatId → заказ и оценка) */
const awaitingRatingComment = new Map<number, { orderId: string; score: number }>();
async function localeOf(from: Context['from']): Promise<Locale> {
if (!from) return 'ru';
@@ -82,11 +57,9 @@ async function askConsent(c: Context, locale: Locale) {
await c.reply(t('bot.client.consentText', { url: `${LANDING}/${locale}/privacy` }), { reply_markup: kb, parse_mode: 'HTML', link_preview_options: { is_disabled: true } });
}
const planName = (plan: string) => (plan === 'ELITE' ? 'Barev Elite' : 'Barev+');
async function showMain(c: Context, locale: Locale, gift?: { endsAt: Date; plan: string } | null) {
async function showMain(c: Context, locale: Locale, gift?: { endsAt: Date } | null) {
const t = createT(locale);
if (gift) await c.reply(t('bot.client.giftActivated', { plan: planName(gift.plan), date: formatDateTime(locale, gift.endsAt, { dateStyle: 'long' }) }));
if (gift) await c.reply(t('bot.client.giftActivated', { date: formatDateTime(locale, gift.endsAt, { dateStyle: 'long' }) }));
await c.reply(t('bot.client.welcome'), { reply_markup: mainKeyboard(locale) });
}
@@ -96,107 +69,19 @@ async function continueOnboarding(c: Context, locale: Locale) {
return showMain(c, locale);
}
// ───── заявка с сайта: доспросить район, адрес и время ─────
async function webRequestDone(c: Context, locale: Locale, link: LinkResult) {
const t = createT(locale);
if (link.kind === 'ordered') {
const date = formatDateTime(locale, link.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
await c.reply(t('bot.client.webRequestOrdered', { request: link.requestNumber, order: link.orderNumber, date }));
} else if (link.kind === 'linked' || link.kind === 'incomplete') {
await c.reply(t('bot.client.webRequestLinked', { request: link.requestNumber }));
}
}
/** Следующий вопрос по заявке: сначала район, потом адрес, потом день и время */
async function webRequestStep(c: Context, locale: Locale, clientId: string, link: Extract<LinkResult, { kind: 'incomplete' }>, first = false) {
const t = createT(locale);
const chatId = c.chat!.id;
if (first) await c.reply(t('bot.client.wr.intro', { request: link.requestNumber }));
const next = link.missing[0];
if (next === 'district') {
const kb = new InlineKeyboard();
const districts = await db.district.findMany({ orderBy: { sortOrder: 'asc' } });
districts.forEach((d, i) => {
kb.text(t(`districts.${d.code}`), `wrd:${link.requestId}:${d.code}`);
if (i % 2 === 1) kb.row();
});
await c.reply(t('bot.client.wr.askDistrict'), { reply_markup: kb });
return;
}
if (next === 'address') {
awaitingAddress.set(chatId, link.requestId);
await c.reply(t('bot.client.wr.askAddress'));
return;
}
const days = await webRequestSlots(ctx, { requestId: link.requestId, clientId });
if (!days.length) {
await c.reply(t('bot.client.wr.noSlots'));
return showMain(c, locale);
}
const kb = new InlineKeyboard();
days.slice(0, 8).forEach((d, i) => {
kb.text(formatDateTime(locale, d.starts[0]!, { weekday: 'short', day: 'numeric', month: 'short' }), `wrday:${link.requestId}:${d.dateKey}`);
if (i % 2 === 1) kb.row();
});
await c.reply(t('bot.client.wr.askDay'), { reply_markup: kb });
}
async function webRequestAnswer(c: Context, input: { requestId: string; districtCode?: string; addressText?: string; preferredAt?: Date }) {
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
if (!client) return;
const locale = client.user.locale as Locale;
try {
const res = await completeWebRequest(ctx, { ...input, clientId: client.id });
if (res.kind === 'incomplete') return webRequestStep(c, locale, client.id, res);
await webRequestDone(c, locale, res);
return showMain(c, locale);
} catch (e) {
if (e instanceof DomainError) return void (await c.reply(createT(locale)(`errors.${e.code}`)));
throw e;
}
}
bot.callbackQuery(/^wrd:(\w+):(\w+)$/, async (c) => {
await c.answerCallbackQuery();
await c.editMessageReplyMarkup().catch(() => undefined);
await webRequestAnswer(c, { requestId: c.match[1]!, districtCode: c.match[2]! });
});
bot.callbackQuery(/^wrday:(\w+):(\d{4}-\d{2}-\d{2})$/, async (c) => {
await c.answerCallbackQuery();
const client = await getClientByTelegramId(db, BigInt(c.from.id));
if (!client) return;
const locale = client.user.locale as Locale;
const t = createT(locale);
const [day] = await webRequestSlots(ctx, { requestId: c.match[1]!, clientId: client.id, dateKey: c.match[2]! }).catch(() => []);
if (!day) return void (await c.reply(t('bot.client.wr.dayFull')));
const kb = new InlineKeyboard();
day.starts.slice(0, 24).forEach((s, i) => {
kb.text(formatDateTime(locale, s, { hour: '2-digit', minute: '2-digit' }), `wrt:${c.match[1]}:${Math.round(s.getTime() / 60_000).toString(36)}`);
if (i % 4 === 3) kb.row();
});
await c.editMessageText(t('bot.client.wr.askTime', { day: formatDateTime(locale, day.starts[0]!, { weekday: 'long', day: 'numeric', month: 'long' }) }), { reply_markup: kb }).catch(() => c.reply(t('bot.client.wr.askTime'), { reply_markup: kb }));
});
bot.callbackQuery(/^wrt:(\w+):([0-9a-z]+)$/, async (c) => {
await c.answerCallbackQuery();
await c.editMessageReplyMarkup().catch(() => undefined);
await webRequestAnswer(c, { requestId: c.match[1]!, preferredAt: new Date(parseInt(c.match[2]!, 36) * 60_000) });
});
bot.command('start', async (c) => {
const res = await startClient(ctx, c.from!, c.match || undefined);
// пришёл с сайта после заявки: язык и согласие уже есть — сразу говорим, что с заявкой
const token = decodeStartPayload(c.match).token;
const link = token ? await linkWebRequest(ctx, token, res.clientId) : null;
if (link && link.kind !== 'none') {
if (link.kind === 'incomplete') {
const gift = link.gift ?? res.gift;
if (gift) await c.reply(createT(res.locale)('bot.client.giftActivated', { plan: planName(gift.plan), date: formatDateTime(res.locale, gift.endsAt, { dateStyle: 'long' }) }));
return webRequestStep(c, res.locale, res.clientId, link, true);
const t = createT(res.locale);
if (link.kind === 'ordered') {
const date = formatDateTime(res.locale, link.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
await c.reply(t('bot.client.webRequestOrdered', { request: link.requestNumber, order: link.orderNumber, date }));
} else {
await c.reply(t('bot.client.webRequestLinked', { request: link.requestNumber }));
}
await webRequestDone(c, res.locale, link);
return showMain(c, res.locale, link.gift ?? res.gift);
}
if (res.needsLocale) {
@@ -255,118 +140,19 @@ bot.command('app', async (c) => {
bot.command('help', async (c) => c.reply(createT(await localeOf(c.from))('bot.client.help')));
// ───── барики ─────
const fmtPts = (locale: Locale, n: number) => new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale).format(n);
const pointsKb = (locale: Locale) => (APP_URL.startsWith('https://') ? new InlineKeyboard().webApp(createT(locale)('bot.client.points.open'), `${APP_URL}?r=/points`) : undefined);
bot.command('balance', async (c) => {
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
const locale = await localeOf(c.from);
if (!client) return;
const t = createT(locale);
const p = await clientPoints(ctx, client.id);
const lines = [t('bot.client.points.balance', { balance: fmtPts(locale, p.balance) })];
if (p.pending) lines.push(t('bot.client.points.pending', { pending: fmtPts(locale, p.pending), date: p.pendingNextAt ? formatDateTime(locale, p.pendingNextAt, { day: 'numeric', month: 'long' }) : '—' }));
if (p.burnAt) lines.push(t('bot.client.points.burn', { date: formatDateTime(locale, p.burnAt, { day: 'numeric', month: 'long' }) }));
await c.reply(lines.join('\n'), { reply_markup: pointsKb(locale) });
});
bot.command('history', async (c) => {
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
const locale = await localeOf(c.from);
if (!client) return;
const t = createT(locale);
const rows = await pointsHistory(db, client.id, { take: 10 });
if (!rows.length) return void (await c.reply(t('bot.client.points.empty')));
const name = (m: Record<string, string> | null) => (m ? m[locale] || m.ru || '' : '');
const text = rows
.map((r) => {
const sign = r.amount > 0 ? '+' : '−';
const what = r.type === 'bonus' ? name(r.bonus) : r.type === 'accrual' ? name(r.category) : t(`bot.client.points.type.${r.type}`);
return `${formatDateTime(locale, r.at, { day: '2-digit', month: '2-digit' })} ${sign}${fmtPts(locale, Math.abs(r.amount))} · ${what}${r.orderRef ? ` ${r.orderRef}` : ''} · ${t(`bot.client.points.status.${r.status}`)}`;
})
.join('\n');
await c.reply(text, { reply_markup: pointsKb(locale) });
});
/** Сообщение в чат команды: подписчик пишет консьержу, остальные — поддержке. Ответ придёт сюда же */
async function toTeam(c: Context, text: string, file?: ChatFile) {
const locale = await localeOf(c.from);
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
if (!client?.consentAt) return askConsent(c, locale);
awaitingCode.delete(c.chat!.id);
const res = await addClientMessage(ctx, client.id, text, file);
// диалог только что открылся — подтверждаем, что сообщение дошло
if (res?.opened) await c.reply(createT(locale)(res.channel === 'CONCIERGE' ? 'bot.client.conciergeReceived' : 'bot.client.supportReceived'));
}
/** Переключить канал: /support — поддержка, /concierge — консьерж (только с подпиской) */
async function switchChannel(c: Context, channel: ChatChannel) {
const locale = await localeOf(c.from);
const t = createT(locale);
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
if (!client?.consentAt) return askConsent(c, locale);
try {
await setClientChannel(ctx, client.id, channel);
await c.reply(t(channel === 'CONCIERGE' ? 'bot.client.channelConcierge' : 'bot.client.channelSupport'));
} catch (e) {
if (e instanceof DomainError) await c.reply(t(`errors.${e.code}`));
else throw e;
}
}
bot.command('support', (c) => switchChannel(c, 'SUPPORT'));
bot.command('concierge', (c) => switchChannel(c, 'CONCIERGE'));
/** Фото и файлы — тоже в чат команды; берём самое крупное превью фото */
bot.on('message:photo', async (c) => {
const photo = c.message.photo.at(-1)!;
await toTeam(c, c.message.caption ?? '', { tgFileId: photo.file_id, tgBot: 'client', fileName: 'photo.jpg', mime: 'image/jpeg', size: photo.file_size ?? null });
});
bot.on('message:document', async (c) => {
const d = c.message.document;
await toTeam(c, c.message.caption ?? '', { tgFileId: d.file_id, tgBot: 'client', fileName: d.file_name ?? 'file', mime: d.mime_type ?? 'application/octet-stream', size: d.file_size ?? null });
});
/** Оценка заказа кнопкой со звёздами (сообщение приходит, когда заказ выполнен) */
bot.callbackQuery(/^rate:(\w+):([1-5])$/, async (c) => {
await c.answerCallbackQuery();
const client = await getClientByTelegramId(db, BigInt(c.from.id));
if (!client) return;
const t = createT(client.user.locale as Locale);
const score = Number(c.match[2]);
try {
await rateOrder(ctx, { orderId: c.match[1]!, clientId: client.id, score });
} catch (e) {
if (e instanceof DomainError) return void (await c.reply(t(`errors.${e.code}`)));
throw e;
}
await c.editMessageText(t('bot.client.rated', { stars: '★'.repeat(score) + '☆'.repeat(5 - score) }));
if (score <= 3) {
awaitingRatingComment.set(c.chat!.id, { orderId: c.match[1]!, score });
await c.reply(t('bot.client.ratedLow'));
}
});
/** ONB-6: ручной ввод кода агентства; любой другой текст — в чат команды */
/** ONB-6: ручной ввод кода агентства; любой другой текст — консьержу */
bot.on('message:text', async (c) => {
const locale = await localeOf(c.from);
const t = createT(locale);
const client = await getClientByTelegramId(db, BigInt(c.from.id));
// комментарий к низкой оценке: сохраняем в оценку и передаём команде в чат
const pending = awaitingRatingComment.get(c.chat.id);
if (pending && client) {
awaitingRatingComment.delete(c.chat.id);
await rateOrder(ctx, { orderId: pending.orderId, clientId: client.id, score: pending.score, comment: c.message.text.slice(0, 1000) }).catch(() => undefined);
return toTeam(c, c.message.text);
if (!awaitingCode.has(c.chat.id)) {
if (!client?.consentAt) return askConsent(c, locale);
// чат с консьержем: сообщение видит оператор в админке, ответ придёт сюда же
const first = !(await db.conciergeMessage.count({ where: { clientId: client.id } }));
await addClientMessage(ctx, client.id, c.message.text);
if (first) await c.reply(t('bot.client.conciergeReceived'));
return;
}
// адрес для заявки с сайта
const requestId = awaitingAddress.get(c.chat.id);
if (requestId && client) {
awaitingAddress.delete(c.chat.id);
return webRequestAnswer(c, { requestId, addressText: c.message.text });
}
if (!awaitingCode.has(c.chat.id)) return toTeam(c, c.message.text);
if (!client) return;
try {
const res = await applyAgencyCode(ctx, client.id, c.message.text);
@@ -394,10 +180,6 @@ async function main() {
{ command: 'start', description: t('bot.client.cmdStart') },
{ command: 'app', description: t('bot.client.cmdApp') },
{ command: 'code', description: t('bot.client.cmdCode') },
{ command: 'balance', description: t('bot.client.cmdBalance') },
{ command: 'history', description: t('bot.client.cmdHistory') },
{ command: 'support', description: t('bot.client.cmdSupport') },
{ command: 'concierge', description: t('bot.client.cmdConcierge') },
{ command: 'language', description: t('bot.client.cmdLanguage') },
{ command: 'help', description: t('bot.client.cmdHelp') },
],
+16 -159
View File
@@ -4,28 +4,7 @@
*/
import { getDb } from '@barev/db';
import { createT, formatDateTime, type Locale } from '@barev/i18n';
import {
startTextOverrides,
acceptPartnerInvite,
addProviderMessage,
checkCard,
accruePoints,
confirmRedemption,
getSettings,
previewAccrual,
rejectRedemption,
createProWebLogin,
DomainError,
linkByInvite,
peerName,
proIdentity,
redeemOffer,
replyTo,
respondToOffer,
staffSend,
type Ctx,
type Peer,
} from '@barev/services';
import { acceptPartnerInvite, addProviderMessage, checkCard, DomainError, linkByInvite, proIdentity, redeemOffer, replyTo, respondToOffer, type Ctx, type Peer } from '@barev/services';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier } from '@barev/services/queue';
import { Bot, InlineKeyboard, type Context } from 'grammy';
@@ -39,7 +18,6 @@ if (!token) console.log('PRO_BOT_TOKEN не задан — бот исполни
const db = getDb();
const notifier = createQueueNotifier(process.env.REDIS_URL ?? 'redis://localhost:6379');
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
startTextOverrides(db);
const APP_URL = process.env.PRO_MINIAPP_URL ?? '';
const bot = new Bot(token || 'disabled:token');
@@ -48,25 +26,12 @@ async function identity(c: Context) {
}
const localeOf = (id: Awaited<ReturnType<typeof identity>>): Locale => (id?.user.locale as Locale) ?? 'ru';
function cabinetKeyboard(locale: Locale, manager = false) {
function cabinetKeyboard(locale: Locale) {
const kb = new InlineKeyboard();
if (APP_URL.startsWith('https://')) kb.webApp(createT(locale)('bot.pro.openCabinet'), APP_URL).row();
// менеджер партнёра может открыть кабинет и на компьютере
if (manager) kb.text(createT(locale)('bot.pro.openWeb'), 'web');
if (APP_URL.startsWith('https://')) kb.webApp(createT(locale)('bot.pro.openCabinet'), APP_URL);
return kb;
}
/** Одноразовая ссылка на кабинет в браузере (15 минут) */
async function sendWebLink(c: Context) {
const id = await identity(c);
const t = createT(localeOf(id));
if (!id || id.companies.length === 0) return void (await c.reply(t('bot.pro.notLinked')));
const code = await createProWebLogin(ctx, id.user.id);
const url = `${APP_URL || 'http://localhost:5175'}/?login=${code}#/company`;
const kb = url.startsWith('https://') ? new InlineKeyboard().url(t('bot.pro.openWeb'), url) : undefined;
await c.reply(t('bot.pro.webLink', { url }), { reply_markup: kb, link_preview_options: { is_disabled: true } });
}
async function greet(c: Context) {
const id = await identity(c);
const locale = localeOf(id);
@@ -80,7 +45,7 @@ async function greet(c: Context) {
...id.companies.map((co) => t('bot.pro.youAreDispatcher', { company: co.name })),
...id.partners.map((p) => t(p.role === 'OWNER' ? 'bot.partner.youAreOwner' : 'bot.partner.youAreStaff', { partner: p.name })),
].filter(Boolean);
await c.reply(`${roles.join('\n')}\n\n${t('bot.pro.welcome')}`, { reply_markup: cabinetKeyboard(locale, id.companies.length > 0) });
await c.reply(`${roles.join('\n')}\n\n${t('bot.pro.welcome')}`, { reply_markup: cabinetKeyboard(locale) });
}
/** Оператор админки, привязавший этот Telegram */
@@ -88,7 +53,7 @@ const adminOf = (c: Context) => (c.from ? db.adminUser.findFirst({ where: { tele
const adminT = createT('ru');
/** Оператор нажал «Ответить» — следующий его текст уйдёт этому клиенту или исполнителю */
const replyingTo = new Map<number, { threadId?: string; peer?: Peer; name: string }>();
const replyingTo = new Map<number, Peer & { name: string }>();
const LEVEL_NAME: Record<string, string> = { BAREV: 'Barev', BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' };
type Check = Awaited<ReturnType<typeof checkCard>>;
@@ -192,11 +157,6 @@ bot.command('start', async (c) => {
});
bot.command('app', greet);
bot.command('web', sendWebLink);
bot.callbackQuery('web', async (c) => {
await c.answerCallbackQuery();
await sendWebLink(c);
});
/** PRO-5: принять / отклонить из уведомления */
bot.callbackQuery(/^offer:(accept|decline):(\w+)$/, async (c) => {
@@ -238,109 +198,19 @@ bot.callbackQuery(/^rd:(\w+):(\w+)$/, async (c) => {
});
/** «Ответить» под уведомлением о сообщении */
// ───── барики: быстрые действия команды ─────
/** Кто может управлять бариками из бота: суперадмин или поддержка, если разрешено в настройках */
async function pointsActor(c: Context) {
const admin = await adminOf(c);
if (!admin) return null;
const owner = admin.roles.includes('SUPERADMIN');
if (!owner && !(admin.roles.includes('SUPPORT') && (await getSettings(db))['points.supportCanManage'])) return null;
return { adminId: admin.id, owner };
}
const fmt = (n: number) => new Intl.NumberFormat('ru-RU').format(n);
async function pointsError(c: Context, e: unknown) {
if (!(e instanceof DomainError)) throw e;
await c.reply(adminT(`errors.${e.code}`));
}
bot.callbackQuery(/^pr:(ok|no):(\w+)$/, async (c) => {
await c.answerCallbackQuery();
const actor = await pointsActor(c);
if (!actor) return void (await c.reply(adminT('bot.admin.notAdmin')));
const r = await db.pointsRedemption.findUnique({ where: { id: c.match[2]! }, include: { order: true } });
if (!r) return;
try {
if (c.match[1] === 'ok') {
if (!r.order) return;
const res = await confirmRedemption(ctx, { redemptionId: r.id, orderAmount: r.order.totalPrice }, actor);
await c.editMessageReplyMarkup().catch(() => undefined);
await c.reply(adminT('bot.admin.points.confirmed', { amount: fmt(res.amount) }));
} else {
await rejectRedemption(ctx, { redemptionId: r.id, reason: 'declined' }, actor);
await c.editMessageReplyMarkup().catch(() => undefined);
await c.reply(adminT('bot.admin.points.rejected'));
}
} catch (e) {
await pointsError(c, e);
}
});
/** /barik <телефон или @username> <сумма заказа> [категория] — подсказка и кнопка «Начислить» */
bot.command('barik', async (c) => {
const actor = await pointsActor(c);
if (!actor) return void (await c.reply(adminT('bot.admin.notAdmin')));
const parts = (c.match ?? '').trim().split(/\s+/).filter(Boolean);
if (parts.length < 2) return void (await c.reply(adminT('bot.admin.points.help')));
const [who, sumRaw, catRaw] = parts;
const sum = Number(sumRaw!.replace(/[^\d]/g, ''));
const digits = who!.replace(/[^\d]/g, '');
const user = who!.startsWith('@')
? await db.user.findFirst({ where: { username: { equals: who!.slice(1), mode: 'insensitive' } }, include: { client: true } })
: digits.length >= 8
? await db.user.findFirst({ where: { phone: { endsWith: digits.slice(-8) } }, include: { client: true } })
: null;
if (!user?.client) return void (await c.reply(adminT('bot.admin.points.noClient')));
const cat = await db.pointsCategory.findUnique({ where: { code: (catRaw ?? 'cleaning').toLowerCase() } });
if (!cat || !sum) return void (await c.reply(adminT('bot.admin.points.help')));
const p = await previewAccrual(db, { clientId: user.client.id, categoryId: cat.id, orderAmount: sum, serviceDate: new Date() }, await getSettings(db), new Date());
const name = [user.firstName, user.lastName].filter(Boolean).join(' ') || `@${user.username}`;
const catName = (cat.name as Record<string, string>).ru ?? cat.code;
const tier = p.tier === 'ELITE' ? 'Elite' : p.tier === 'BAREV_PLUS' ? 'Barev+' : 'Barev';
const kb = new InlineKeyboard().text(adminT('bot.admin.points.accrue'), `pa:${user.client.id}:${cat.code}:${sum}`);
await c.reply(adminT('bot.admin.points.preview', { amount: fmt(p.suggested), name, what: catName, sum: fmt(sum), tier, rate: p.ratePct, date: formatDateTime('ru', p.availableAt, { day: 'numeric', month: 'long' }) }), { reply_markup: kb });
});
bot.callbackQuery(/^pa:(\w+):([a-z_]+):(\d+)$/, async (c) => {
await c.answerCallbackQuery();
const actor = await pointsActor(c);
if (!actor) return;
const cat = await db.pointsCategory.findUnique({ where: { code: c.match[2]! } });
if (!cat) return;
try {
const r = await accruePoints(ctx, { clientId: c.match[1]!, kind: 'ORDER', categoryId: cat.id, orderAmount: Number(c.match[3]), serviceDate: new Date(), channel: 'BOT', idempotencyKey: `bot:${c.callbackQuery.message?.message_id}:${c.match[1]}` }, actor);
await c.editMessageReplyMarkup().catch(() => undefined);
await c.reply(r.status === 'created' ? adminT('bot.admin.points.done', { amount: fmt(r.amount) }) : r.status === 'needs_approval' ? adminT('bot.admin.points.needsApproval') : adminT('bot.admin.points.zero'));
} catch (e) {
await pointsError(c, e);
}
});
bot.callbackQuery(/^ar:(c|p|t):(\w+)$/, async (c) => {
bot.callbackQuery(/^ar:(c|p):(\w+)$/, async (c) => {
const admin = await adminOf(c);
if (!admin) {
await c.answerCallbackQuery({ text: adminT('bot.admin.notAdmin'), show_alert: true });
return;
}
const [, kind, id] = c.match;
let name = '';
if (kind === 't') {
// ответ в конкретный диалог (поддержка или консьерж)
const thread = await db.chatThread.findUnique({ where: { id: id! }, include: { client: { include: { user: true } }, provider: true } });
if (!thread) {
await c.answerCallbackQuery({ text: adminT('errors.NOT_FOUND'), show_alert: true });
return;
}
name = peerName(thread);
replyingTo.set(c.chat!.id, { threadId: thread.id, name });
} else {
const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
name =
kind === 'c'
? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
: ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
replyingTo.set(c.chat!.id, { peer, name });
}
const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
const name =
kind === 'c'
? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
: ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
replyingTo.set(c.chat!.id, { ...peer, name });
await c.answerCallbackQuery();
await c.reply(adminT('bot.admin.replyPrompt', { name }), { reply_markup: { force_reply: true, input_field_placeholder: adminT('bot.admin.replyPlaceholder') } });
});
@@ -356,8 +226,7 @@ bot.on('message:text', async (c) => {
const admin = await adminOf(c);
if (target && admin) {
try {
if (target.threadId) await staffSend(ctx, { threadId: target.threadId, adminUserId: admin.id, text: c.message.text });
else await replyTo(ctx, { ...target.peer!, adminUserId: admin.id, text: c.message.text });
await replyTo(ctx, { ...(target.clientId ? { clientId: target.clientId } : { providerId: target.providerId! }), adminUserId: admin.id, text: c.message.text });
replyingTo.delete(c.chat.id);
await c.reply(adminT('bot.admin.replySent', { name: target.name }));
} catch (e) {
@@ -369,8 +238,9 @@ bot.on('message:text', async (c) => {
// исполнитель пишет оператору — сообщение появится в админке
const id = await identity(c);
if (id?.provider) {
const res = await addProviderMessage(ctx, id.provider.id, c.message.text);
if (res?.opened) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
const first = !(await db.conciergeMessage.count({ where: { providerId: id.provider.id } }));
await addProviderMessage(ctx, id.provider.id, c.message.text);
if (first) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
return;
}
if (admin) {
@@ -380,19 +250,6 @@ bot.on('message:text', async (c) => {
await greet(c);
});
/** Исполнитель прислал фото или файл — тоже в поддержку */
bot.on(['message:photo', 'message:document'], async (c, next) => {
const id = await identity(c);
if (!id?.provider) return next();
const photo = c.message.photo?.at(-1);
const doc = c.message.document;
const file = photo
? { tgFileId: photo.file_id, tgBot: 'pro' as const, fileName: 'photo.jpg', mime: 'image/jpeg', size: photo.file_size ?? null }
: { tgFileId: doc!.file_id, tgBot: 'pro' as const, fileName: doc!.file_name ?? 'file', mime: doc!.mime_type ?? 'application/octet-stream', size: doc!.file_size ?? null };
const res = await addProviderMessage(ctx, id.provider.id, c.message.caption ?? '', file);
if (res?.opened) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
});
bot.on('message', greet);
bot.catch((err) => console.error('[bot-pro]', err.error));
-33
View File
@@ -455,33 +455,6 @@ button.cl-extra {
color: #e9c9d5;
}
/* телефон сразу под комнатами и санузлами — единственное обязательное поле, кроме параметров */
.cl-hero-phone {
max-width: 420px;
margin-top: 4px;
}
.cl-hero-phone .bc-input {
background: #fff;
}
.cl-hero-phone[data-error='true'] .bc-input {
border-color: var(--bc-danger);
}
/* согласие на обработку данных — нажатием кнопки */
.cl-consent-note {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: #e9c9d5;
}
.cl-consent-note a {
color: inherit;
}
.cl-consent-note--mobile {
display: none;
color: var(--bc-ink-2, #6b5d57);
padding: 0 4px;
}
/* нижняя панель — только на телефоне */
.cl-bar {
display: none;
@@ -686,12 +659,6 @@ button.cl-extra {
.cl-receipt .cl-btn-pink {
display: none;
}
.cl-receipt .cl-consent-note {
display: none;
}
.cl-consent-note--mobile {
display: block;
}
.cl-bar {
position: fixed;
left: 12px;
+47 -53
View File
@@ -19,11 +19,10 @@ const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
const AGENCY_KEY = 'barev.agency';
const TYPES = ['regular', 'general', 'renovation'] as const;
type Errors = Partial<Record<'phone' | 'submit', string>>;
type Errors = Partial<Record<'time' | 'district' | 'street' | 'phone' | 'consent' | 'submit', string>>;
/**
* Страница /clean (модель qlean.ru): параметры и телефон → доп. услуги → время → адрес → имя.
* Обязательны только тип уборки, комнаты, санузлы и телефон; время и адрес бот доспросит, а оператор уточнит.
* Страница /clean (модель qlean.ru): параметры → доп. услуги → время → адрес → контакты.
* Цена считается в браузере той же функцией quote, что и на сервере. Заявка сразу видна в админке,
* а человек уходит в бота по ссылке, которая привязывает заявку к его Telegram.
*/
@@ -82,6 +81,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
const [note, setNote] = useState('');
const [phone, setPhone] = useState('');
const [name, setName] = useState('');
const [consent, setConsent] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [sending, setSending] = useState(false);
const [done, setDone] = useState<{ number: number; url: string } | null>(null);
@@ -96,7 +96,11 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
function validate(): Errors {
const e: Errors = {};
if (!time) e.time = t('landing.clean.err.time');
if (!district) e.district = t('landing.clean.err.district');
if (street.trim().length < 3) e.street = t('landing.clean.err.street');
if (!/^\+?\d{8,15}$/.test(phone.replace(/[\s()\-.]/g, ''))) e.phone = t('landing.clean.err.phone');
if (!consent) e.consent = t('landing.clean.err.consent');
return e;
}
@@ -123,10 +127,9 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
body: JSON.stringify({
json: {
params,
preferredAt: time?.toISOString(),
districtCode: district || undefined,
// короткий адрес не отправляем — бот спросит его заново
addressText: street.trim().length >= 3 ? street.trim() : undefined,
preferredAt: time!.toISOString(),
districtCode: district,
addressText: street.trim(),
note: note.trim() || undefined,
phone,
name: name.trim() || undefined,
@@ -208,30 +211,6 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
<Stepper value={baths} min={bathsF.min ?? 1} max={bathsF.max ?? 4} onChange={setBaths} decLabel={t('common.decrease')} incLabel={t('common.increase')} />
</div>
</div>
<label className="bc-field cl-hero-phone" data-error={!!errors.phone}>
<span className="bc-label">{t('landing.clean.phone')}</span>
<input
className="bc-input"
type="tel"
inputMode="tel"
autoComplete="tel"
value={phone}
maxLength={30}
placeholder="+374 91 123456"
aria-invalid={!!errors.phone}
aria-describedby="cl-phone-hint"
onChange={(e) => setPhone(e.target.value)}
/>
{errors.phone ? (
<span className="bc-error" role="alert">
{errors.phone}
</span>
) : (
<span id="cl-phone-hint" className="cl-muted cl-small">
{t('landing.clean.phoneHint')}
</span>
)}
</label>
</div>
<div className="cl-hero__price" aria-live="polite">
<PriceTicker value={q ? amd(q.totalPrice) : '…'} />
@@ -275,7 +254,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
</Block>
)}
<Block title={t('landing.clean.whenTitle')} hint={t('landing.clean.whenHintOptional')}>
<Block title={t('landing.clean.whenTitle')} hint={t('landing.clean.whenHint')} error={errors.time}>
<div className="cl-days" role="listbox" aria-label={t('landing.clean.whenTitle')}>
{days.map((d) => (
<button
@@ -305,11 +284,11 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
)}
</Block>
<Block title={t('landing.clean.addressTitle')} hint={t('landing.clean.addressHintOptional')}>
<Block title={t('landing.clean.addressTitle')} error={errors.district ?? errors.street}>
<div className="cl-grid2">
<label className="bc-field">
<span className="bc-label">{t('landing.clean.district')}</span>
<select className="bc-input" value={district} onChange={(e) => setDistrict(e.target.value)}>
<select className="bc-input" value={district} onChange={(e) => setDistrict(e.target.value)} aria-invalid={!!errors.district}>
<option value="">{t('landing.clean.districtPlaceholder')}</option>
{config.districts.map((d) => (
<option key={d} value={d}>
@@ -326,6 +305,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
maxLength={300}
placeholder={t('landing.clean.streetPlaceholder')}
autoComplete="street-address"
aria-invalid={!!errors.street}
onChange={(e) => setStreet(e.target.value)}
/>
</label>
@@ -336,14 +316,41 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
</label>
</Block>
<Block title={t('landing.clean.nameTitle')}>
<label className="bc-field">
<span className="bc-label">{t('landing.clean.name')}</span>
<input className="bc-input" value={name} maxLength={80} autoComplete="given-name" onChange={(e) => setName(e.target.value)} />
<Block title={t('landing.clean.contactTitle')} error={errors.phone ?? errors.consent}>
<div className="cl-grid2">
<label className="bc-field">
<span className="bc-label">{t('landing.clean.phone')}</span>
<input
className="bc-input"
type="tel"
inputMode="tel"
autoComplete="tel"
value={phone}
maxLength={30}
placeholder="+374 91 123456"
aria-invalid={!!errors.phone}
aria-describedby="cl-phone-hint"
onChange={(e) => setPhone(e.target.value)}
/>
<span id="cl-phone-hint" className="cl-muted cl-small">
{t('landing.clean.phoneHint')}
</span>
</label>
<label className="bc-field">
<span className="bc-label">{t('landing.clean.name')}</span>
<input className="bc-input" value={name} maxLength={80} autoComplete="given-name" onChange={(e) => setName(e.target.value)} />
</label>
</div>
<label className="cl-consent">
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} aria-invalid={!!errors.consent} />
<span>
{t('landing.clean.consent')}.{' '}
<a href={`/${locale}/privacy`} target="_blank" rel="noopener">
{t('landing.clean.privacy')}
</a>
</span>
</label>
</Block>
{/* на телефоне кнопка в нижней панели — текст о согласии под формой */}
<ConsentNote locale={locale} t={t} className="cl-consent-note cl-consent-note--mobile" />
</div>
<aside className="cl-receipt" aria-label={t('landing.clean.summaryTitle')}>
@@ -379,7 +386,6 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
</div>
)}
<p className="cl-receipt__after">{t('landing.clean.after')}</p>
<ConsentNote locale={locale} t={t} />
</aside>
</div>
@@ -397,18 +403,6 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
);
}
/** Согласие на обработку данных — нажатием кнопки, без отдельной галочки */
function ConsentNote({ locale, t, className = 'cl-consent-note' }: { locale: Locale; t: (k: string) => string; className?: string }) {
return (
<p className={className}>
{t('landing.clean.consentByClick')}{' '}
<a href={`/${locale}/privacy`} target="_blank" rel="noopener">
{t('landing.clean.privacy')}
</a>
</p>
);
}
function Block({ title, hint, error, children }: { title: string; hint?: string; error?: string; children: ReactNode }) {
return (
<section className="cl-block" data-error={!!error}>
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -1 +1 @@
{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/libLine truncated
{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/libLine truncated
+14 -18
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Center, Icon, Spinner } from '@barev/ui';
import { I18nProvider, match, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
import { I18nProvider, match, openTelegram, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
import type { ReactNode } from 'react';
import { Consent } from './screens/Consent.tsx';
import { Docs } from './screens/Docs.tsx';
@@ -15,10 +15,8 @@ import { Orders } from './screens/Orders.tsx';
import { Perks } from './screens/Perks.tsx';
import { Profile } from './screens/Profile.tsx';
import { Services } from './screens/Services.tsx';
import { Chat } from './screens/Chat.tsx';
import { Points } from './screens/Points.tsx';
const ROOTS = ['/', '/orders', '/services', '/perks', '/profile', '/docs'];
const ROOTS = ['/', '/services', '/perks', '/profile', '/docs'];
export function App({ hasInitData }: { hasInitData: boolean }) {
const trpc = useTRPC();
@@ -40,7 +38,7 @@ export function App({ hasInitData }: { hasInitData: boolean }) {
) : !me.data.segment ? (
<Segment />
) : (
<Routes segment={me.data.segment} />
<Routes segment={me.data.segment} conciergeUrl={me.data.conciergeUrl} />
)}
</I18nProvider>
);
@@ -50,7 +48,7 @@ export function App({ hasInitData }: { hasInitData: boolean }) {
* Два сценария: MOVING — план переезда, документы, консьерж; LIVING — услуги со скидками, партнёры, заказы.
* Остальные экраны (услуги, подписка, заказы) доступны из обоих.
*/
function Routes({ segment }: { segment: 'MOVING' | 'LIVING' }) {
function Routes({ segment, conciergeUrl }: { segment: 'MOVING' | 'LIVING'; conciergeUrl: string }) {
const { path } = useLocation();
useTelegramBack(path, ROOTS);
let screen: ReactNode;
@@ -65,20 +63,18 @@ function Routes({ segment }: { segment: 'MOVING' | 'LIVING' }) {
else if (path === '/perks') screen = <Perks />;
else if (path === '/profile') screen = <Profile />;
else if (path === '/membership') screen = <Membership />;
else if (path === '/chat') screen = <Chat />;
else if (path === '/points') screen = <Points />;
else screen = segment === 'MOVING' ? <Move /> : <Home />;
const withNav = ROOTS.includes(path);
const withNav = ROOTS.includes(path) || path === '/orders';
return (
<>
{screen}
{withNav && (segment === 'MOVING' ? <MoveNav path={path} /> : <BottomNav path={path} />)}
{withNav && (segment === 'MOVING' ? <MoveNav path={path} conciergeUrl={conciergeUrl} /> : <BottomNav path={path} />)}
</>
);
}
/** Меню переезда: План / Документы / Консьерж (чат с ботом) / Услуги / Профиль */
function MoveNav({ path }: { path: string }) {
function MoveNav({ path, conciergeUrl }: { path: string; conciergeUrl: string }) {
const { t } = useI18n();
const item = (href: string, icon: Parameters<typeof Icon>[0]['name'], label: string) => (
<a href={`#${href}`} aria-current={path === href ? 'page' : undefined}>
@@ -90,16 +86,16 @@ function MoveNav({ path }: { path: string }) {
<nav className="bc-bottomnav" aria-label={t('client.nav.label')}>
{item('/', 'globe', t('client.nav.plan'))}
{item('/docs', 'docs', t('client.nav.docs'))}
<a href="#/chat" className="bc-bottomnav__plus" aria-label={t('client.concierge.title')}>
<button type="button" className="bc-bottomnav__plus" onClick={() => openTelegram(conciergeUrl)} aria-label={t('client.concierge.title')}>
<Icon name="chat" size={24} strokeWidth={2.4} />
</a>
</button>
{item('/services', 'grid', t('client.nav.services'))}
{item('/profile', 'user', t('client.nav.profile'))}
</nav>
);
}
/** Главная / Заказы / Чат (в центре) / Клубная карта / Профиль; услуги — с главной */
/** ORD-1: Главная / Услуги / + / Скидки / Профиль */
function BottomNav({ path }: { path: string }) {
const { t } = useI18n();
const item = (href: string, icon: Parameters<typeof Icon>[0]['name'], label: string) => (
@@ -111,11 +107,11 @@ function BottomNav({ path }: { path: string }) {
return (
<nav className="bc-bottomnav" aria-label={t('client.nav.label')}>
{item('/', 'home', t('client.nav.home'))}
{item('/orders', 'clock', t('client.nav.orders'))}
<a href="#/chat" className="bc-bottomnav__plus" aria-label={t('client.nav.chat')}>
<Icon name="chat" size={24} strokeWidth={2.4} />
{item('/services', 'grid', t('client.nav.services'))}
<a href="#/order/cleaning" className="bc-bottomnav__plus" aria-label={t('client.nav.newOrder')}>
<Icon name="plus" size={26} strokeWidth={2.6} />
</a>
{item('/perks', 'gift', t('client.nav.card'))}
{item('/perks', 'gift', t('client.nav.perks'))}
{item('/profile', 'user', t('client.nav.profile'))}
</nav>
);
-256
View File
@@ -779,259 +779,3 @@
background: var(--bc-bg);
color: var(--bc-ink-3);
}
/* ───── чат с командой ───── */
.chat-screen {
max-width: 560px;
margin: 0 auto;
height: 100dvh;
display: flex;
flex-direction: column;
}
.chat-screen__head {
padding: 16px 16px 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-switch {
display: flex;
padding: 4px;
gap: 4px;
border-radius: 999px;
background: var(--bc-surface);
}
.chat-switch button {
flex: 1;
height: 36px;
border: 0;
border-radius: 999px;
background: none;
font: inherit;
font-weight: 600;
font-size: 14px;
color: var(--bc-ink-2);
}
.chat-switch button[aria-pressed='true'] {
background: var(--bc-plum);
color: #f6e9dc;
}
.chat-upsell {
align-self: flex-start;
padding: 6px 12px;
border-radius: 999px;
background: var(--bc-pink);
color: var(--bc-plum);
font-weight: 600;
text-decoration: none;
}
.chat-list {
flex: 1;
overflow-y: auto;
padding: 8px 16px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-hint {
text-align: center;
margin: 4px 12px 8px;
}
.chat-day {
align-self: center;
margin: 10px 0 4px;
padding: 3px 12px;
border-radius: 999px;
background: var(--bc-surface);
font-size: 12px;
font-weight: 600;
color: var(--bc-ink-3);
}
.chat-msg {
align-self: flex-start;
max-width: 82%;
padding: 9px 13px 7px;
border-radius: 20px 20px 20px 6px;
background: var(--bc-surface);
line-height: 1.4;
overflow-wrap: anywhere;
}
.chat-msg--mine {
align-self: flex-end;
border-radius: 20px 20px 6px 20px;
background: var(--bc-plum);
color: #f6e9dc;
}
.chat-msg__who {
font-size: 12px;
font-weight: 700;
color: var(--bc-accent);
margin-bottom: 2px;
}
.chat-msg__text {
white-space: pre-wrap;
}
.chat-msg__time {
font-size: 11px;
opacity: 0.6;
text-align: right;
margin-top: 2px;
}
.chat-msg__img {
display: block;
max-width: 100%;
max-height: 260px;
border-radius: 14px;
margin: 2px -5px 6px;
}
.chat-msg__file {
display: block;
font-weight: 600;
color: inherit;
margin-bottom: 4px;
}
.chat-compose {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 8px 12px calc(10px + env(safe-area-inset-bottom));
background: var(--bc-bg);
}
.chat-compose__input {
flex: 1;
min-height: 44px;
max-height: 140px;
padding: 11px 16px;
border-radius: 22px;
border: 1.5px solid var(--bc-line);
background: var(--bc-surface);
font: inherit;
font-size: 16px;
resize: none;
field-sizing: content;
}
.chat-compose__input:focus {
outline: none;
border-color: var(--bc-accent);
}
.chat-compose__btn,
.chat-compose__send {
width: 44px;
height: 44px;
flex: none;
border-radius: 50%;
border: 0;
display: grid;
place-items: center;
background: var(--bc-surface);
color: var(--bc-ink);
}
.chat-compose__send {
background: var(--bc-accent);
color: #fff;
}
.chat-compose__send:disabled {
opacity: 0.4;
}
/* барики в форме заказа */
.points-toggle {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px 14px;
border-radius: 18px;
background: var(--bc-perks, #f6e3b4);
cursor: pointer;
}
.points-toggle[data-off] {
opacity: 0.6;
cursor: default;
}
.points-toggle input {
width: 22px;
height: 22px;
margin-top: 1px;
accent-color: var(--bc-plum);
flex: none;
}
.points-toggle small {
display: block;
font-size: 13px;
opacity: 0.8;
margin-top: 2px;
}
/* ───── барики ───── */
.points-hero {
gap: 6px;
}
.points-hero__num {
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 46px;
line-height: 1;
letter-spacing: -0.03em;
}
.points-hero__ghost {
color: #fff;
border-color: rgba(255, 255, 255, 0.5);
}
.points-rates {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.points-rates th,
.points-rates td {
padding: 6px 4px;
text-align: right;
}
.points-rates th:first-child,
.points-rates td:first-child {
text-align: left;
}
.points-rates th {
font-weight: 600;
opacity: 0.6;
}
.points-rates [data-on] {
font-weight: 800;
opacity: 1;
color: var(--bc-plum);
}
.points-dots {
display: flex;
gap: 8px;
}
.points-dots span {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bc-bg);
border: 2px solid rgba(74, 16, 40, 0.15);
}
.points-dots span[data-mark] {
border-color: var(--bc-plum);
}
.points-dots span[data-on] {
background: var(--bc-plum);
border-color: var(--bc-plum);
}
.points-row {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 4px;
border-bottom: 1px solid rgba(74, 16, 40, 0.08);
}
.points-row:last-child {
border-bottom: 0;
}
.points-plus {
color: var(--bc-home-ink, #2f5b1f);
white-space: nowrap;
}
.points-minus {
white-space: nowrap;
}
+11 -139
View File
@@ -1,4 +1,4 @@
import { CATEGORY_ICON, CATEGORY_TONE, Icon, type CategoryCode } from '@barev/ui';
import { CATEGORY_TONE, Icon, type CategoryCode } from '@barev/ui';
import { paramsSummary, useI18n } from '@barev/web';
import type { ReactNode } from 'react';
@@ -38,31 +38,19 @@ export function useParamsSummary() {
return (serviceCode: string, params: Record<string, unknown>) => paramsSummary(t, serviceCode, params);
}
/** Карточка заказа в списке: иконка направления, услуга, время, статус и полоска прогресса */
export function OrderRow({ o }: { o: OrderLike & { active?: boolean; steps?: readonly string[]; rating?: { score: number } | null; done?: boolean } }) {
/** Строка заявки как в превью «Мои заявки» макета */
export function OrderRow({ o }: { o: OrderLike }) {
const { t, dt } = useI18n();
const summary = useParamsSummary();
const idx = o.steps ? o.steps.indexOf(o.status) : -1;
const progress = o.active && o.steps && idx >= 0 ? (idx + 1) / o.steps.length : null;
return (
<a href={`#/orders/${o.id}`} className="order-card" data-status={o.status === 'CANCELLED' ? 'cancelled' : o.active ? 'active' : 'done'}>
<span className={`order-card__icon ${toneClass(o.category)}`}>
<Icon name={CATEGORY_ICON[o.category as CategoryCode] ?? 'home'} size={22} />
</span>
<span className="order-card__main">
<span className="order-card__title">{t(`services.${o.serviceCode}.name`)}</span>
<span className="order-card__sub">{summary(o.serviceCode, o.params)}</span>
<span className="order-card__when">{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
{progress !== null && (
<span className="order-card__bar" aria-hidden="true">
<span style={{ width: `${Math.round(progress * 100)}%` }} />
</span>
)}
</span>
<span className="order-card__side">
<span className="order-card__status">{t(`status.${o.flow}.${o.status}`)}</span>
{o.rating ? <Stars value={o.rating.score} size={12} /> : o.done ? <span className="order-card__rate">{t('client.rate.prompt')}</span> : null}
</span>
<a href={`#/orders/${o.id}`} className={`order-row ${toneClass(o.category)}`}>
<div className="order-row__main">
<div className="order-row__title">
{t(`services.${o.serviceCode}.name`)} · {summary(o.serviceCode, o.params)}
</div>
<div className="bc-small">{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</div>
</div>
<span className="bc-badge">{t(`status.${o.flow}.${o.status}`)}</span>
</a>
);
}
@@ -105,119 +93,3 @@ export function itemHint(names: RelocationNames | undefined, kind: 'STEP' | 'DOC
const h = names?.[`${kind}:${item.code}`]?.hint;
return h?.[locale] || h?.ru || null;
}
export interface TimelineItem {
label: ReactNode;
state: 'done' | 'current' | 'todo';
meta?: ReactNode;
}
/** Вертикальная шкала как на сайте: зелёные точки — пройдено, розовая — сейчас */
export function Timeline({ items, compact }: { items: TimelineItem[]; compact?: boolean }) {
return (
<ol className={`tl${compact ? ' tl--compact' : ''}`}>
{items.map((s, i) => (
<li key={i} className={`tl__item tl__item--${s.state}`}>
<span className="tl__dot" aria-hidden="true">
{s.state === 'done' && <Icon name="check" size={12} strokeWidth={3} />}
</span>
<span className="tl__body">
<span className="tl__label">{s.label}</span>
{s.meta && <span className="tl__meta">{s.meta}</span>}
</span>
</li>
))}
</ol>
);
}
export interface OrderSteps {
flow: string;
status: string;
/** false — заказ завершён: последний шаг тоже зелёный */
active?: boolean;
steps: readonly string[];
history: { to: string; event: string | null; at: Date }[];
}
/** Шаги заказа по истории статусов: пройденные, текущий, остальные */
export function useOrderTimeline() {
const { t, dt } = useI18n();
return (o: OrderSteps): TimelineItem[] => {
const passed = new Set(o.history.map((h) => h.to));
if (o.status === 'CANCELLED')
return [...o.steps.filter((s) => passed.has(s)).map((s) => ({ label: t(`status.${o.flow}.${s}`), state: 'done' as const })), { label: t(`status.${o.flow}.CANCELLED`), state: 'current' as const }];
const currentIdx = o.steps.indexOf(o.status);
return o.steps.map((s, i) => {
const at = o.history.find((h) => h.to === s && !h.event)?.at ?? o.history.find((h) => h.to === s)?.at;
return {
label: t(`status.${o.flow}.${s}`),
state: i < currentIdx || (i === currentIdx && o.active === false) ? 'done' : i === currentIdx ? 'current' : 'todo',
meta: at && i <= currentIdx ? dt(at, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : undefined,
};
});
};
}
const STAR = 'M12 3l2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1L3.2 9.5l6.1-.9z';
/** Звёзды 1–5; без onChange — только показ */
export function Stars({ value, onChange, size = 34 }: { value: number; onChange?: (v: number) => void; size?: number }) {
const { t } = useI18n();
return (
<div className="stars" role={onChange ? 'radiogroup' : 'img'} aria-label={onChange ? undefined : t('client.rate.star', { count: value })}>
{[1, 2, 3, 4, 5].map((n) => {
const svg = (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
<path d={STAR} fill={n <= value ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={1.8} strokeLinejoin="round" />
</svg>
);
return onChange ? (
<button key={n} type="button" role="radio" aria-checked={n === value} aria-label={t('client.rate.star', { count: n })} data-on={n <= value || undefined} onClick={() => onChange(n)}>
{svg}
</button>
) : (
<span key={n} data-on={n <= value || undefined}>
{svg}
</span>
);
})}
</div>
);
}
/** Клубная карта-«билет» в стиле карт на сайте */
export function ClubTicket({ level, name, code, until, href, action }: { level: Level; name?: string; code?: string | null; until?: ReactNode; href?: string; action?: ReactNode }) {
const id = level === 'BAREV' ? 'free' : level === 'BAREV_PLUS' ? 'plus' : 'elite';
const body = (
<>
<span className="ticket__main">
<span className="ticket__club">Barev Club</span>
<span className="ticket__level">{LEVEL_NAME[level]}</span>
<span className="ticket__meta">
{name && <b>{name}</b>}
{code && <span className="bc-num">{code}</span>}
</span>
{until && <span className="ticket__until">{until}</span>}
</span>
{action && <span className="ticket__stub">{action}</span>}
</>
);
return href ? (
<a className="ticket" data-plan={id} href={href}>
{body}
</a>
) : (
<div className="ticket" data-plan={id}>
{body}
</div>
);
}
export function QrGlyph({ size = 28 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinejoin="round" aria-hidden="true">
<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h2v2h-2zM18 14h2M14 18v2M18 18h2v2h-2z" />
</svg>
);
}
+1 -4
View File
@@ -1,14 +1,11 @@
import '@barev/ui/styles.css';
import './app.css';
import './redesign.css';
import { ApiProvider, loadTextOverrides, getInitData, initTelegram, ToastProvider } from '@barev/web';
import { ApiProvider, getInitData, initTelegram, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
initTelegram();
// названия и описания услуг, изменённые в админке
void loadTextOverrides();
const initData = getInitData();
createRoot(document.getElementById('root')!).render(
-965
View File
@@ -1,965 +0,0 @@
/* Этап 6: новый вид клиентского приложения — в стиле barevclub.com (слива, розовый, зелёные точки) */
:root {
--plum-2: #5e1a36;
--plum-line: rgba(246, 195, 211, 0.22);
--green: #a8c38a;
--green-ink: #1f3311;
}
/* ───── сливовый верх, как на сайте ───── */
.app--hero {
padding-top: 0;
}
.hero {
margin: 0 -16px 4px;
padding: 18px 16px 22px;
background: var(--bc-plum);
color: #fff;
border-radius: 0 0 32px 32px;
display: flex;
flex-direction: column;
gap: 14px;
position: relative;
overflow: hidden;
}
.hero__word {
position: absolute;
right: -10px;
top: -18px;
font-family: var(--bc-font-head);
font-weight: 900;
font-style: italic;
font-size: 112px;
line-height: 1;
letter-spacing: -0.04em;
color: var(--bc-pink);
opacity: 0.16;
pointer-events: none;
}
.hero__hello {
position: relative;
margin: 6px 0 0;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 30px;
line-height: 1.05;
letter-spacing: -0.01em;
}
.hero__hello span {
color: var(--bc-pink);
}
.hero__hello small {
display: block;
font-family: var(--bc-font-body);
font-weight: 500;
font-size: 15px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 2px;
}
.hero__note {
margin: 0;
font-size: 14px;
line-height: 1.4;
color: rgba(255, 255, 255, 0.78);
}
/* ───── клубная карта-«билет» ───── */
.ticket {
position: relative;
display: flex;
align-items: stretch;
min-height: 132px;
border-radius: 24px;
text-decoration: none;
color: var(--bc-plum);
background: var(--bc-pink);
overflow: hidden;
}
.ticket[data-plan='free'] {
background: var(--plum-2);
color: #fff;
box-shadow: inset 0 0 0 1.5px var(--plum-line);
}
.ticket[data-plan='elite'] {
background: var(--bc-perks);
}
.ticket__main {
flex: 1;
min-width: 0;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 2px;
}
.ticket__club {
font-size: 12px;
font-weight: 600;
opacity: 0.75;
}
.ticket__level {
font-family: var(--bc-font-head);
font-weight: 900;
font-style: italic;
font-size: 34px;
line-height: 1;
letter-spacing: -0.02em;
margin-top: 2px;
}
.ticket__meta {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 10px;
font-size: 13px;
font-weight: 600;
}
.ticket__until {
font-size: 12px;
opacity: 0.8;
}
/* корешок: пунктир и вырезы, как у билета */
.ticket__stub {
flex: none;
width: 92px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
font-size: 11px;
font-weight: 700;
line-height: 1.15;
padding: 10px 8px;
border-left: 2px dashed currentColor;
border-left-color: color-mix(in srgb, currentColor 35%, transparent);
position: relative;
}
.ticket__stub::before,
.ticket__stub::after {
content: '';
position: absolute;
left: -11px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bc-plum);
}
.ticket__stub::before {
top: -10px;
}
.ticket__stub::after {
bottom: -10px;
}
/* ───── секции ───── */
.sect {
display: flex;
flex-direction: column;
gap: 10px;
}
.sect__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
}
.sect__title {
margin: 4px 0 0;
display: flex;
align-items: center;
gap: 8px;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 22px;
letter-spacing: -0.01em;
}
.sect__link {
font-size: 14px;
font-weight: 600;
color: var(--bc-accent);
text-decoration: none;
}
.live-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 4px rgba(168, 195, 138, 0.3);
animation: live 2s ease-in-out infinite;
}
@keyframes live {
50% {
box-shadow: 0 0 0 7px rgba(168, 195, 138, 0.1);
}
}
/* ───── «Сейчас» ───── */
.now-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border-radius: 28px;
background: var(--bc-surface);
text-decoration: none;
color: var(--bc-ink);
box-shadow: var(--bc-shadow);
}
.now-card__top {
display: flex;
align-items: center;
gap: 12px;
}
.now-card__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.now-card__main b {
font-family: var(--bc-font-head);
font-size: 18px;
line-height: 1.15;
}
.now-card__main span {
font-size: 13px;
color: var(--bc-ink-2);
}
.now-card__when {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 16px;
background: var(--bc-bg);
font-weight: 600;
font-size: 14px;
}
.now-card__when::first-letter {
text-transform: uppercase;
}
.more-link {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--bc-ink-2);
text-decoration: none;
}
.empty-now {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 18px;
border-radius: 28px;
background: var(--bc-surface);
}
.empty-now p {
margin: 0;
color: var(--bc-ink-2);
}
/* ───── шкала как на сайте ───── */
.tl {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.tl__item {
position: relative;
display: flex;
gap: 12px;
padding-bottom: 14px;
}
.tl__item:last-child {
padding-bottom: 0;
}
.tl__item::before {
content: '';
position: absolute;
left: 10px;
top: 22px;
bottom: 0;
width: 2px;
background: var(--bc-line);
}
.tl__item--done::before {
background: var(--green);
}
.tl__item:last-child::before {
display: none;
}
.tl__dot {
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--bc-line);
background: var(--bc-surface);
display: grid;
place-items: center;
color: var(--green-ink);
position: relative;
z-index: 1;
}
.tl__item--done .tl__dot {
background: var(--green);
border-color: var(--green);
}
.tl__item--current .tl__dot {
border: 6px solid var(--bc-accent);
box-shadow: 0 0 0 4px rgba(184, 35, 90, 0.15);
}
.tl__body {
display: flex;
flex-direction: column;
padding-top: 1px;
min-width: 0;
}
.tl__label {
font-weight: 600;
font-size: 15px;
line-height: 1.3;
}
.tl__item--todo .tl__label {
color: var(--bc-ink-3);
font-weight: 500;
}
.tl__item--current .tl__label {
color: var(--bc-accent);
}
.tl__meta {
font-size: 12px;
color: var(--bc-ink-3);
}
/* компактно на главной — шаги в строку */
.tl--compact {
flex-direction: row;
}
.tl--compact .tl__item {
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 0;
min-width: 0;
}
.tl--compact .tl__item::before {
left: 22px;
right: 0;
top: 10px;
bottom: auto;
width: auto;
height: 2px;
}
.tl--compact .tl__item:last-child {
flex: none;
}
.tl--compact .tl__body {
display: none;
}
.tl--compact .tl__item--current .tl__body {
display: flex;
position: absolute;
top: 28px;
left: 0;
white-space: nowrap;
}
.tl--compact .tl__item--current .tl__meta {
display: none;
}
.tl--compact {
padding-bottom: 24px;
}
.tl--compact .tl__item:last-child.tl__item--current .tl__body {
left: auto;
right: 0;
}
/* ───── карточки заказов ───── */
.order-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.order-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border-radius: 24px;
background: var(--bc-surface);
text-decoration: none;
color: var(--bc-ink);
}
.order-card[data-status='cancelled'] {
opacity: 0.6;
}
.order-card__icon {
flex: none;
width: 44px;
height: 44px;
border-radius: 16px;
display: grid;
place-items: center;
}
.order-card__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.order-card__title {
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 16px;
}
.order-card__sub,
.order-card__when {
font-size: 13px;
color: var(--bc-ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.order-card__when {
color: var(--bc-ink);
font-weight: 500;
}
.order-card__bar {
margin-top: 8px;
height: 4px;
border-radius: 4px;
background: var(--bc-line);
overflow: hidden;
}
.order-card__bar span {
display: block;
height: 100%;
border-radius: 4px;
background: var(--green);
}
.order-card__side {
flex: none;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.order-card__status {
padding: 5px 10px;
border-radius: var(--bc-pill);
background: var(--bc-bg);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.order-card[data-status='active'] .order-card__status {
background: var(--bc-plum);
color: #fff;
}
.order-card__rate {
font-size: 12px;
font-weight: 600;
color: var(--bc-accent);
}
.order-card .stars {
color: #e9a400;
}
/* ───── карточка заказа ───── */
.od-hero {
margin: -16px -16px 0;
padding: 12px 16px 22px;
border-radius: 0 0 32px 32px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.od-hero__bar {
align-self: stretch;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.od-hero__num {
font-family: var(--bc-font-head);
font-weight: 700;
opacity: 0.6;
}
.od-hero__status {
padding: 6px 12px;
border-radius: var(--bc-pill);
background: var(--bc-plum);
color: #fff;
font-size: 13px;
font-weight: 600;
}
.od-hero__status[data-status='done'] {
background: var(--bc-surface);
color: var(--green-ink);
}
.od-hero__status[data-status='cancelled'] {
background: var(--bc-surface);
color: var(--bc-danger);
}
.od-hero__title {
margin: 4px 0 0;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 32px;
line-height: 1.02;
letter-spacing: -0.02em;
}
.od-hero__sub {
font-size: 15px;
opacity: 0.85;
}
.facts {
margin: 0;
padding: 4px 16px;
border-radius: 24px;
background: var(--bc-surface);
}
.facts > div {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 0;
border-top: 1px solid var(--bc-line);
}
.facts > div:first-child {
border-top: 0;
}
.facts dt {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--bc-ink-3);
}
.facts dd {
margin: 0 0 0 24px;
font-weight: 600;
}
.facts dd::first-letter {
text-transform: uppercase;
}
.panel {
padding: 16px;
border-radius: 24px;
background: var(--bc-surface);
}
.panel__title {
margin: 0 0 12px;
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 18px;
}
/* ───── оценка ───── */
.rate {
padding: 18px;
border-radius: 28px;
background: var(--bc-plum);
color: #fff;
display: flex;
flex-direction: column;
gap: 10px;
}
.rate__title {
margin: 0;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 22px;
}
.rate__text {
margin: -4px 0 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.75);
}
.rate .stars {
color: var(--bc-perks);
}
.rate__input {
width: 100%;
min-height: 64px;
padding: 12px 14px;
border-radius: 18px;
border: 0;
font: inherit;
font-size: 15px;
resize: vertical;
box-sizing: border-box;
}
.rate--done {
background: var(--bc-surface);
color: var(--bc-ink);
}
.rate--done .stars {
color: #e9a400;
}
.rate__label {
font-size: 13px;
font-weight: 600;
color: var(--bc-ink-3);
}
.rate__change {
border: 0;
background: none;
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--bc-accent);
cursor: pointer;
}
.rate__comment {
margin: 0;
color: var(--bc-ink-2);
}
.rate-nudge {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: 24px;
background: var(--bc-perks);
color: var(--bc-perks-ink);
text-decoration: none;
}
.rate-nudge > span {
display: flex;
flex-direction: column;
min-width: 0;
font-size: 13px;
}
.rate-nudge b {
font-family: var(--bc-font-head);
font-size: 16px;
}
.stars {
display: flex;
gap: 4px;
}
.stars button {
padding: 2px;
border: 0;
background: none;
color: inherit;
cursor: pointer;
line-height: 0;
transition: transform 0.12s;
}
.stars button:active {
transform: scale(0.88);
}
.stars [data-on] svg {
filter: none;
}
/* ───── кнопки-«таблетки» как на сайте ───── */
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 46px;
padding: 0 20px;
border-radius: var(--bc-pill);
border: 0;
font: inherit;
font-weight: 600;
font-size: 15px;
text-decoration: none;
cursor: pointer;
white-space: nowrap;
}
.pill:disabled {
opacity: 0.6;
}
.pill--block {
width: 100%;
}
.pill--dark {
background: var(--bc-plum);
color: #fff;
}
.pill--pink {
background: var(--bc-pink);
color: var(--bc-plum);
}
.pill--green {
background: var(--green);
color: var(--green-ink);
}
.screen-head .pill--dark {
width: 46px;
padding: 0;
}
/* ───── заголовок экрана ───── */
.screen-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 8px;
}
.screen-head__title {
margin: 0;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 32px;
letter-spacing: -0.02em;
}
/* ───── направления ───── */
.tiles .tile {
min-height: 118px;
border-radius: 26px;
justify-content: flex-start;
gap: 6px;
}
.tiles .tile .tile__title {
margin-top: auto;
font-size: 18px;
}
.tile__note {
font-size: 12px;
opacity: 0.8;
}
/* ───── чат ───── */
.chat-cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px;
border-radius: 28px;
background: var(--bc-plum);
color: #fff;
text-decoration: none;
}
.chat-cta > span:first-child {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
}
.chat-cta b {
font-family: var(--bc-font-head);
font-size: 17px;
color: #fff;
}
/* ───── QR-карта ───── */
.hero--card {
align-items: stretch;
padding-top: 22px;
}
.hero--card .pill {
align-self: flex-start;
}
.qr-card {
border-radius: 28px;
padding: 16px;
background: var(--bc-pink);
color: var(--bc-plum);
display: flex;
flex-direction: column;
gap: 12px;
}
.qr-card[data-plan='free'] {
background: var(--plum-2);
color: #fff;
box-shadow: inset 0 0 0 1.5px var(--plum-line);
}
.qr-card[data-plan='elite'] {
background: var(--bc-perks);
}
.qr-card__top {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
font-weight: 600;
}
.qr-card__top b {
font-family: var(--bc-font-head);
font-weight: 900;
font-style: italic;
font-size: 26px;
}
.qr-card__code {
align-self: center;
padding: 12px;
border-radius: 22px;
background: #fff;
line-height: 0;
}
.qr-card__meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 13px;
}
.qr-card__timer {
opacity: 0.75;
}
.bc-segmented button[aria-pressed='true'] {
background: var(--bc-plum);
color: #fff;
}
/* ───── общий вид: слива вместо чёрного ───── */
.bc-btn--dark,
.bc-chip[aria-pressed='true'],
.day[aria-pressed='true'],
.slot[aria-pressed='true'] {
background: var(--bc-plum);
color: #fff;
}
.option[aria-pressed='true'] {
border-color: var(--bc-plum);
background: var(--bc-pink);
color: var(--bc-plum);
}
.extra-toggle[aria-pressed='true'] {
background: var(--green);
border-color: var(--green);
color: var(--green-ink);
}
.bc-input:focus {
border-color: var(--bc-plum);
}
.app .bc-card {
border-radius: 26px;
}
.page-head {
padding-top: 4px;
}
.page-head .bc-h2 {
font-weight: 800;
font-size: 28px;
}
/* закреплённый итог в оформлении заказа — сливовый, кнопка розовая */
.app .sticky-cta {
background: var(--bc-plum);
color: #fff;
box-shadow: 0 12px 30px rgba(74, 16, 40, 0.35) !important;
}
.app .sticky-cta .bc-muted {
color: rgba(255, 255, 255, 0.72);
}
.app .sticky-cta .bc-btn {
background: var(--bc-pink);
color: var(--bc-plum);
}
/* верх переезда: блок плана внутри сливового верха */
.hero.move-hero {
gap: 6px;
}
.hero.move-hero .hero__hello {
margin-bottom: 10px;
}
/* промокод */
.promo-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.promo-form .pill {
min-height: 50px;
}
.promo-form__error {
grid-column: 1 / -1;
}
.promo-applied {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 10px 10px 16px;
border-radius: 18px;
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
.promo-applied > span {
display: flex;
flex-direction: column;
}
.promo-applied b {
font-family: var(--bc-font-head);
letter-spacing: 0.04em;
}
.promo-applied button {
border: 0;
background: rgba(255, 255, 255, 0.6);
color: inherit;
font: inherit;
font-weight: 600;
font-size: 14px;
padding: 8px 14px;
border-radius: var(--bc-pill);
cursor: pointer;
}
/* ссылки в профиле */
.links {
display: flex;
flex-direction: column;
padding: 4px 16px;
border-radius: 26px;
background: var(--bc-surface);
}
.links a {
display: flex;
align-items: center;
gap: 12px;
min-height: 54px;
border-top: 1px solid var(--bc-line);
color: var(--bc-ink);
text-decoration: none;
font-weight: 600;
}
.links a:first-child {
border-top: 0;
}
.links a span {
flex: 1;
}
/* ───── нижнее меню — слива и розовая кнопка чата ───── */
.bc-bottomnav {
background: var(--bc-plum);
box-shadow: 0 10px 30px rgba(74, 16, 40, 0.35);
}
.bc-bottomnav a,
.bc-bottomnav button {
color: rgba(246, 195, 211, 0.6);
}
.bc-bottomnav [aria-current='page'] {
color: var(--bc-pink);
}
.bc-bottomnav .bc-bottomnav__plus {
background: var(--bc-pink);
color: var(--bc-plum);
}
-162
View File
@@ -1,162 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Center, Icon, Spinner } from '@barev/ui';
import { errorText, getInitData, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { PageHead } from '../lib.tsx';
const MAX_FILE = 10 * 1024 * 1024;
/**
* Чат с командой Barev Club: та же переписка, что в чате с ботом.
* Подписчик пишет личному консьержу, остальные — в поддержку; ответ приходит и сюда, и в Telegram.
*/
export function Chat() {
const { t, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const chat = useQuery({ ...trpc.client.chat.get.queryOptions(), refetchInterval: 5_000 });
const [text, setText] = useState('');
const [uploading, setUploading] = useState(false);
const list = useRef<HTMLDivElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const refresh = () => qc.invalidateQueries({ queryKey: trpc.client.chat.get.queryKey() });
const send = useMutation(
trpc.client.chat.send.mutationOptions({
onMutate: () => haptic.tap(),
onSuccess: () => {
setText('');
void refresh();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const setChannel = useMutation(trpc.client.chat.setChannel.mutationOptions({ onSuccess: () => void refresh(), onError: (e) => toast(errorText(t, e), 'error') }));
const lastId = chat.data?.messages.at(-1)?.id;
useLayoutEffect(() => {
list.current?.scrollTo({ top: list.current.scrollHeight });
}, [lastId]);
const upload = async (file: File) => {
if (file.size > MAX_FILE) return toast(t('client.chat.fileTooBig'), 'error');
setUploading(true);
try {
const form = new FormData();
form.set('text', text);
form.set('file', file);
const res = await fetch('/files/client/chat', { method: 'POST', body: form, headers: { authorization: `tma ${getInitData()}` } });
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? String(res.status));
setText('');
void refresh();
} catch (e) {
toast(errorText(t, e), 'error');
} finally {
setUploading(false);
}
};
if (!chat.data)
return (
<Center>
<Spinner />
</Center>
);
const d = chat.data;
let prevDay = '';
return (
<main className="chat-screen">
<div className="chat-screen__head">
<PageHead title={t('client.chat.title')} />
{d.canConcierge ? (
<div className="chat-switch" role="group">
{(['CONCIERGE', 'SUPPORT'] as const).map((c) => (
<button key={c} type="button" aria-pressed={d.channel === c} disabled={setChannel.isPending} onClick={() => d.channel !== c && setChannel.mutate({ channel: c })}>
{t(c === 'CONCIERGE' ? 'client.chat.concierge' : 'client.chat.support')}
</button>
))}
</div>
) : (
<a href="#/membership" className="chat-upsell bc-small">
{t('client.chat.conciergeOnly')} →
</a>
)}
</div>
<div className="chat-list" ref={list}>
<p className="chat-hint bc-small bc-muted">{t('client.chat.hint')}</p>
{d.messages.length === 0 && <p className="chat-hint">{t('client.chat.empty')}</p>}
{d.messages.map((m) => {
const day = dt(m.createdAt, { day: 'numeric', month: 'long' });
const sep = day !== prevDay ? <div className="chat-day">{day}</div> : null;
prevDay = day;
return (
<Fragment key={m.id}>
{sep}
<div className={`chat-msg ${m.mine ? 'chat-msg--mine' : ''}`}>
{!m.mine && <div className="chat-msg__who">{t(m.channel === 'CONCIERGE' ? 'client.chat.concierge' : 'client.chat.support')}</div>}
{m.file && <ChatFile id={m.id} file={m.file} />}
{m.text && <div className="chat-msg__text">{m.text}</div>}
<div className="chat-msg__time">{dt(m.createdAt, { hour: '2-digit', minute: '2-digit' })}</div>
</div>
</Fragment>
);
})}
</div>
<form
className="chat-compose"
onSubmit={(e) => {
e.preventDefault();
if (text.trim() && !send.isPending) send.mutate({ text });
}}
>
<button type="button" className="chat-compose__btn" onClick={() => fileInput.current?.click()} disabled={uploading} aria-label={t('client.chat.attach')}>
{uploading ? <Spinner /> : <Icon name="camera" size={22} />}
</button>
<input
ref={fileInput}
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);
}}
/>
<textarea className="chat-compose__input" rows={1} value={text} maxLength={4000} placeholder={t('client.chat.placeholder')} onChange={(e) => setText(e.target.value)} aria-label={t('client.chat.placeholder')} />
<button type="submit" className="chat-compose__send" disabled={!text.trim() || send.isPending} aria-label={t('client.chat.send')}>
<Icon name="arrow" size={22} strokeWidth={2.4} />
</button>
</form>
</main>
);
}
/** Файл из переписки: картинку показываем, остальное — ссылкой. Доступ по initData, поэтому через fetch */
function ChatFile({ id, file }: { id: string; file: { name: string | null; mime: string | null } }) {
const { t } = useI18n();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let revoke: string | null = null;
fetch(`/files/client/chat-file/${id}`, { headers: { authorization: `tma ${getInitData()}` } })
.then((r) => (r.ok ? r.blob() : null))
.then((b) => {
if (!b) return;
revoke = URL.createObjectURL(b);
setUrl(revoke);
})
.catch(() => {});
return () => {
if (revoke) URL.revokeObjectURL(revoke);
};
}, [id]);
if (file.mime?.startsWith('image/') && url) return <img className="chat-msg__img" src={url} alt={file.name ?? ''} />;
return (
<a className="chat-msg__file" href={url ?? undefined} download={file.name ?? undefined} target="_blank" rel="noreferrer">
📎 {file.name ?? t('client.chat.file')}
</a>
);
}
+33 -65
View File
@@ -1,14 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, Center, Icon, Spinner } from "@barev/ui";
import {
errorText,
haptic,
navigate,
useI18n,
useToast,
useTRPC,
} from "@barev/web";
import { itemHint, itemName } from "../lib.tsx";
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Card, Center, Icon, Spinner } from '@barev/ui';
import { errorText, haptic, openTelegram, useI18n, useToast, useTRPC } from '@barev/web';
import { itemHint, itemName, PageHead } from '../lib.tsx';
/** Чек-лист документов, которые лучше собрать до переезда; отметки сохраняются в заявке на переезд */
export function Docs() {
@@ -21,11 +14,8 @@ export function Docs() {
const toggle = useMutation(
trpc.client.relocation.toggleDoc.mutationOptions({
onMutate: () => haptic.tap(),
onSuccess: () =>
void qc.invalidateQueries({
queryKey: trpc.client.relocation.get.queryKey(),
}),
onError: (e) => toast(errorText(t, e), "error"),
onSuccess: () => void qc.invalidateQueries({ queryKey: trpc.client.relocation.get.queryKey() }),
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
if (!plan.data)
@@ -38,63 +28,41 @@ export function Docs() {
return (
<main className="app">
<header className="screen-head">
<h1 className="screen-head__title">{t("client.docs.title")}</h1>
</header>
<p className="bc-muted" style={{ margin: "-6px 0 0" }}>
{t("client.docs.sub")}
<PageHead title={t('client.docs.title')} back={false} />
<p className="bc-muted" style={{ margin: 0 }}>
{t('client.docs.sub')}
</p>
{!lead && (
<Card tone="perks" tight>
<a
href="#/"
className="bc-small"
style={{ fontWeight: 600, color: "inherit" }}
>
{t("client.docs.needPlan")}
<a href="#/" className="bc-small" style={{ fontWeight: 600, color: 'inherit' }}>
{t('client.docs.needPlan')}
</a>
</Card>
)}
{lead && lead.docs.length > 0 && (
<Card style={{ padding: 8 }}>
<ul className="docs">
{(lead?.docs ?? []).map((d) => {
const hint = itemHint(plan.data.names, "DOC", d, locale);
return (
<li key={d.id}>
<button
className="doc"
aria-pressed={d.checked}
disabled={toggle.isPending}
onClick={() =>
toggle.mutate({ docId: d.id, checked: !d.checked })
}
>
<span className="doc__box" aria-hidden="true">
{d.checked && (
<Icon name="check" size={16} strokeWidth={3} />
)}
</span>
<span>
<b>{itemName(plan.data.names, "DOC", d, locale)}</b>
{hint && (
<span className="bc-small bc-muted">{hint}</span>
)}
</span>
</button>
</li>
);
})}
</ul>
</Card>
)}
<Card style={{ padding: 8 }}>
<ul className="docs">
{(lead?.docs ?? []).map((d) => {
const hint = itemHint(plan.data.names, 'DOC', d, locale);
return (
<li key={d.id}>
<button className="doc" aria-pressed={d.checked} disabled={toggle.isPending} onClick={() => toggle.mutate({ docId: d.id, checked: !d.checked })}>
<span className="doc__box" aria-hidden="true">
{d.checked && <Icon name="check" size={16} strokeWidth={3} />}
</span>
<span>
<b>{itemName(plan.data.names, 'DOC', d, locale)}</b>
{hint && <span className="bc-small bc-muted">{hint}</span>}
</span>
</button>
</li>
);
})}
</ul>
</Card>
{me.data && (
<button
className="bc-btn bc-btn--light bc-btn--block"
onClick={() => navigate("/chat")}
>
<button className="bc-btn bc-btn--light bc-btn--block" onClick={() => openTelegram(me.data.conciergeUrl)}>
<Icon name="chat" size={18} />
{t("client.docs.ask")}
{t('client.docs.ask')}
</button>
)}
</main>
+78 -123
View File
@@ -1,148 +1,103 @@
import { useQuery } from '@tanstack/react-query';
import { CATEGORY_ICON, Icon, Spinner, type CategoryCode } from '@barev/ui';
import { useI18n, useTRPC } from '@barev/web';
import { ClubTicket, LEVEL_NAME, levelDiscount, QrGlyph, Stars, Timeline, toneClass, useOrderTimeline, useParamsSummary, type Level } from '../lib.tsx';
import { CATEGORY_ICON, Card, Chip, Icon, Spinner, type CategoryCode } from '@barev/ui';
import { navigate, useI18n, useTRPC } from '@barev/web';
import { LEVEL_NAME, LevelBadge, levelDiscount, OrderRow, toneClass, type Level } from '../lib.tsx';
/**
* Главная для тех, кто уже живёт в Армении.
* Сверху — сливовый блок как на сайте: приветствие и клубная карта.
* Дальше — «Сейчас» (ближайший заказ со шкалой), просьба оценить, направления, чат.
*/
/** Главная для тех, кто уже живёт в Армении: заказы, быстрые кнопки, направления со скидкой по уровню */
export function Home() {
const { t, dt } = useI18n();
const trpc = useTRPC();
const me = useQuery(trpc.client.me.queryOptions());
const orders = useQuery({ ...trpc.client.orders.list.queryOptions(), refetchInterval: 20_000 });
const catalog = useQuery(trpc.client.catalog.queryOptions());
const timeline = useOrderTimeline();
const summary = useParamsSummary();
const level = (me.data?.membership.level ?? 'BAREV') as Level;
const now = orders.data?.active[0];
const moreActive = (orders.data?.active.length ?? 0) - 1;
const toRate = orders.data?.history.find((o) => o.done && !o.rating);
const plus = level !== 'BAREV';
const lastDone = orders.data?.history.find((o) => o.status !== 'CANCELLED');
return (
<main className="app app--hero">
<section className="hero">
<div className="hero__word" aria-hidden="true">
Barev
<main className="app">
<header className="bc-between" style={{ alignItems: 'flex-start' }}>
<div>
<div className="bc-small bc-muted">{t('client.home.hello')}</div>
<h1 className="bc-h2">{me.data?.firstName ?? t('client.home.guest')}</h1>
</div>
<h1 className="hero__hello">
<small>{t('client.home.hello')}</small>
<span>{me.data?.firstName ?? t('client.home.guest')}</span>
</h1>
<ClubTicket
level={level}
code={me.data?.cardCode}
href="#/perks"
until={
level !== 'BAREV' && me.data?.membership.endsAt
? t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long' }) })
: t('client.home.free')
}
action={
<>
<QrGlyph />
<span>{t('client.home.cardShow')}</span>
</>
}
/>
</section>
<LevelBadge level={level} />
</header>
{orders.isPending ? (
<Spinner />
) : now ? (
<section className="sect">
<div className="sect__head">
<h2 className="sect__title">
<span className="live-dot" aria-hidden="true" />
{t('client.home.now')}
</h2>
<a href="#/orders" className="sect__link">
{t('client.home.allOrders')}
</a>
{plus && me.data?.membership.endsAt && (
<Card tone="perks" tight>
<div className="bc-row">
<Icon name="gift" />
<div className="bc-small" style={{ fontWeight: 600 }}>
{t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long' }) })}
</div>
</div>
<a href={`#/orders/${now.id}`} className="now-card">
<span className="now-card__top">
<span className={`order-card__icon ${toneClass(now.category)}`}>
<Icon name={CATEGORY_ICON[now.category as CategoryCode] ?? 'home'} size={22} />
</span>
<span className="now-card__main">
<b>{t(`services.${now.serviceCode}.name`)}</b>
<span>{summary(now.serviceCode, now.params)}</span>
</span>
<Icon name="arrow" size={20} />
</span>
<span className="now-card__when">
<Icon name="clock" size={18} />
{dt(now.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
</span>
<Timeline items={timeline(now)} compact />
</a>
{moreActive > 0 && (
<a href="#/orders" className="more-link">
{t('client.home.moreActive', { count: moreActive })}
<Icon name="arrow" size={16} />
</a>
)}
</section>
) : (
<section className="empty-now">
<p>{t('client.home.noOrders')}</p>
<a href="#/order/cleaning" className="pill pill--dark">
<Icon name="plus" size={18} strokeWidth={2.6} />
{t('services.cleaning.name')}
</a>
</section>
</Card>
)}
{toRate && (
<a href={`#/orders/${toRate.id}?rate=1`} className="rate-nudge">
<span>
<b>{t('client.rate.prompt')}</b>
<span>
{t(`services.${toRate.serviceCode}.name`)} · {dt(toRate.scheduledAt, { day: 'numeric', month: 'short' })}
</span>
</span>
<Stars value={0} size={22} />
</a>
)}
<section className="sect">
<div className="sect__head">
<h2 className="sect__title">{t('client.home.orderTitle')}</h2>
<a href="#/services" className="sect__link">
{t('client.nav.services')}
</a>
</div>
<div className="tiles">
{(catalog.data?.categories ?? []).map((c) => (
<a key={c.code} href={c.code === 'PERKS' ? '#/perks' : `#/services?c=${c.code}`} className={`tile ${toneClass(c.code)}`}>
<Icon name={CATEGORY_ICON[c.code as CategoryCode]} size={26} />
<span className="tile__title">{t(`categories.${c.code}.name`)}</span>
<span className="tile__note">{levelDiscount(level, c) > 0 ? t('client.services.yourDiscount', { pct: levelDiscount(level, c) }) : t(`categories.${c.code}.short`)}</span>
</a>
))}
</div>
</section>
{level === 'BAREV' && (
{!plus && (
<a href="#/membership" className="upsell">
<b>{t('client.home.upsellTitle', { pct: me.data?.plans.eliteMaxPct ?? 0 })}</b>
<span className="bc-small">{t('client.home.upsellText')}</span>
</a>
)}
<a href="#/chat" className="chat-cta">
<span>
<b>{t('client.home.chatTitle')}</b>
<span>{t('client.home.chatText')}</span>
</span>
<span className="pill pill--green">
<Icon name="chat" size={18} />
{t('client.nav.chat')}
</span>
</a>
<Card style={{ padding: 16 }}>
<div className="bc-between" style={{ padding: '0 4px 8px' }}>
<h2 className="bc-h3">{t('client.home.myOrders')}</h2>
<a href="#/orders" className="bc-small bc-muted">
{t('client.home.allOrders')}
</a>
</div>
{orders.isPending ? (
<Spinner />
) : orders.data && orders.data.active.length > 0 ? (
<div className="bc-stack" style={{ gap: 8 }}>
{orders.data.active.slice(0, 4).map((o) => (
<OrderRow key={o.id} o={o} />
))}
</div>
) : (
<div className="bc-stack" style={{ padding: '4px 4px 6px' }}>
<p className="bc-muted" style={{ margin: 0 }}>
{t('client.home.noOrders')}
</p>
</div>
)}
</Card>
<div className="quick">
<Chip onClick={() => navigate('/order/cleaning')}>
<Icon name="home" size={18} />
{t('services.cleaning.name')}
</Chip>
{lastDone && (
<Chip onClick={() => navigate(`/order/${lastDone.serviceCode}?repeat=${lastDone.id}`)}>
<Icon name="repeat" size={18} />
{t('client.home.repeatLast')}
</Chip>
)}
{!me.data?.agencyName && (
<Chip onClick={() => navigate('/profile?code=1')}>
<Icon name="gift" size={18} />
{t('client.home.enterCode')}
</Chip>
)}
</div>
<div className="tiles">
{(catalog.data?.categories ?? []).map((c) => (
<a key={c.code} href={c.code === 'PERKS' ? '#/perks' : `#/services?c=${c.code}`} className={`tile ${toneClass(c.code)}`}>
<div className="bc-between" style={{ alignItems: 'flex-start' }}>
<div className="tile__title">{t(`categories.${c.code}.name`)}</div>
<Icon name={CATEGORY_ICON[c.code as CategoryCode]} size={24} />
</div>
<div className="bc-small">
{levelDiscount(level, c) > 0 ? t('client.services.yourDiscount', { pct: levelDiscount(level, c) }) : t(`categories.${c.code}.short`)}
</div>
</a>
))}
</div>
</main>
);
}
+12 -14
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Card, Center, Icon, Spinner } from '@barev/ui';
import { navigate, useI18n, useTRPC } from '@barev/web';
import { openTelegram, useI18n, useTRPC } from '@barev/web';
import { itemName, LevelBadge, type Level } from '../lib.tsx';
import { MoveForm } from './MoveForm.tsx';
@@ -31,18 +31,16 @@ export function Move() {
const days = lead.moveDate ? Math.ceil((new Date(lead.moveDate).getTime() - Date.now()) / DAY) : null;
return (
<main className="app app--hero">
<section className="hero move-hero">
<div className="hero__word" aria-hidden="true">
Barev
</div>
<div className="bc-between" style={{ alignItems: 'flex-start', position: 'relative' }}>
<h1 className="hero__hello">
<small>{t('client.home.hello')}</small>
<span>{me.data.firstName ?? t('client.home.guest')}</span>
</h1>
<LevelBadge level={me.data.membership.level as Level} />
<main className="app">
<header className="bc-between" style={{ alignItems: 'flex-start' }}>
<div>
<div className="bc-small bc-muted">{t('client.home.hello')}</div>
<h1 className="bc-h2">{me.data.firstName ?? t('client.home.guest')}</h1>
</div>
<LevelBadge level={me.data.membership.level as Level} />
</header>
<section className="move-hero">
<div className="move-hero__label">{t('client.move.title')}</div>
<div className="move-hero__when">
{lead.moveWhen === 'date' && lead.moveDate
@@ -57,7 +55,7 @@ export function Move() {
</section>
<Card style={{ padding: '18px 18px 8px' }}>
<h2 className="panel__title">
<h2 className="bc-h3" style={{ marginBottom: 10 }}>
{t(`landing.quiz.package_${lead.package}`)}
</h2>
{steps.length === 0 ? (
@@ -88,7 +86,7 @@ export function Move() {
<b>{t('client.docs.title')}</b>
<span className="bc-small">{t('client.docs.progress', { done: docsDone, total: lead.docs.length })}</span>
</a>
<button className="move-action bc-tone-learn" onClick={() => navigate('/chat')}>
<button className="move-action bc-tone-learn" onClick={() => openTelegram(me.data.conciergeUrl)}>
<Icon name="chat" size={24} />
<b>{t('client.concierge.title')}</b>
<span className="bc-small">{t('client.concierge.short')}</span>
+2 -2
View File
@@ -66,8 +66,8 @@ export function MoveForm() {
);
return (
<main className="app app--hero">
<section className="hero move-hero">
<main className="app">
<section className="move-hero">
<div className="move-hero__label">{me.data?.firstName ? t('client.move.formHello', { name: me.data.firstName }) : t('client.move.title')}</div>
<div className="move-hero__when">{t('client.move.formTitle')}</div>
<div className="move-hero__progress">{t('client.move.formSub')}</div>
+70 -139
View File
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Center, Icon, Sheet, Spinner } from '@barev/ui';
import { confirmDialog, dateKeyToDate, errorText, haptic, navigate, nextDays, paramsExtras, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Stars, Timeline, toneClass, useOrderTimeline, useParamsSummary } from '../lib.tsx';
import { Button, Card, Center, Icon, Sheet, Spinner, Steps } from '@barev/ui';
import { confirmDialog, dateKeyToDate, errorText, haptic, navigate, nextDays, paramsExtras, useI18n, useToast, useTRPC } from '@barev/web';
import { useMemo, useState } from 'react';
import { PageHead, toneClass, useParamsSummary } from '../lib.tsx';
export function OrderDetail({ id }: { id: string }) {
const { t, amd, dt, locale } = useI18n();
@@ -10,7 +10,6 @@ export function OrderDetail({ id }: { id: string }) {
const qc = useQueryClient();
const toast = useToast();
const summary = useParamsSummary();
const timeline = useOrderTimeline();
const order = useQuery({ ...trpc.client.orders.get.queryOptions({ id }), refetchInterval: 15_000 });
const preview = useQuery({ ...trpc.client.orders.cancelPreview.queryOptions({ id }), enabled: !!order.data?.active });
const [reschedule, setReschedule] = useState(false);
@@ -33,8 +32,22 @@ export function OrderDetail({ id }: { id: string }) {
</Center>
);
const o = order.data;
// шкала прогресса: пройденные статусы по истории, текущий, остальные
const passed = new Set(o.history.map((h) => h.to));
const currentIdx = o.steps.indexOf(o.status);
const steps =
o.status === 'CANCELLED'
? [...o.steps.filter((s) => passed.has(s)).map((s) => ({ label: t(`status.${o.flow}.${s}`), state: 'done' as const })), { label: t(`status.${o.flow}.CANCELLED`), state: 'current' as const }]
: o.steps.map((s, i) => {
const at = o.history.find((h) => h.to === s && !h.event)?.at ?? o.history.find((h) => h.to === s)?.at;
return {
label: t(`status.${o.flow}.${s}`),
state: i < currentIdx ? ('done' as const) : i === currentIdx ? ('current' as const) : ('todo' as const),
meta: at && i <= currentIdx ? dt(at, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : undefined,
};
});
const arrived = o.history.some((h) => h.event === 'arrived');
const extras = paramsExtras(t, o.params);
async function onCancel() {
const p = preview.data?.policy;
@@ -45,63 +58,47 @@ export function OrderDetail({ id }: { id: string }) {
return (
<main className="app app--plain">
<section className={`od-hero ${toneClass(o.category)}`}>
<div className="od-hero__bar">
<button className="back" onClick={() => window.history.back()} aria-label={t('common.back')}>
<Icon name="back" />
</button>
<span className="od-hero__num">#{o.number}</span>
</div>
<span className="od-hero__status" data-status={o.status === 'CANCELLED' ? 'cancelled' : o.active ? 'active' : 'done'}>
{t(`status.${o.flow}.${o.status}`)}
{arrived && o.status === 'EN_ROUTE' ? ` · ${t('client.detail.arrived')}` : ''}
</span>
<h1 className="od-hero__title">{t(`services.${o.serviceCode}.name`)}</h1>
<div className="od-hero__sub">
{summary(o.serviceCode, o.params)}
{extras.length > 0 && ` + ${extras.map((e) => e.label).join(', ')}`}
</div>
</section>
<PageHead title={`#${o.number}`} />
<dl className="facts">
<div>
<dt>
<Card className={toneClass(o.category)} style={{ padding: 20 }}>
<div className="bc-stack" style={{ gap: 8 }}>
<div className="bc-between" style={{ alignItems: 'flex-start' }}>
<h2 className="bc-h2">{t(`services.${o.serviceCode}.name`)}</h2>
<span className="bc-badge">{t(`status.${o.flow}.${o.status}`)}</span>
</div>
<div>{summary(o.serviceCode, o.params)}</div>
{paramsExtras(t, o.params).length > 0 && <div className="bc-small">+ {paramsExtras(t, o.params).map((e) => e.label).join(', ')}</div>}
<div className="bc-row" style={{ fontWeight: 600 }}>
<Icon name="clock" size={18} />
{t('client.detail.when')}
</dt>
<dd>{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}</dd>
</div>
{o.addressText && (
<div>
<dt>
{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
</div>
{o.addressText && (
<div className="bc-row">
<Icon name="pin" size={18} />
{t('client.detail.where')}
</dt>
<dd>
{o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''}
{o.addressText}
</dd>
</div>
)}
<div>
<dt>
<Icon name="user" size={18} />
{t(`client.detail.providerLabel.${o.flow}`)}
</dt>
<dd>{o.providerName ?? <span className="bc-muted">{t('client.detail.whoPending')}</span>}</dd>
</div>
)}
{o.providerName && (
<div className="bc-row">
<Icon name="user" size={18} />
{t(`client.detail.providerLabel.${o.flow}`)}: {o.providerName}
{arrived && o.status === 'EN_ROUTE' && <span className="bc-badge">{t('client.detail.arrived')}</span>}
</div>
)}
</div>
</dl>
</Card>
{o.done && <RateBlock id={o.id} rating={o.rating} />}
<section className="panel">
<h2 className="panel__title">{t('client.detail.progress')}</h2>
<Timeline items={timeline(o)} />
</section>
<Card>
<h2 className="bc-h3" style={{ marginBottom: 12 }}>
{t('client.detail.progress')}
</h2>
<Steps items={steps} />
</Card>
{o.photos.length > 0 && <PhotoReport photos={o.photos} />}
<section className="panel price-lines">
<Card className="price-lines">
<div className="bc-between">
<span>{t('client.order.price')}</span>
<span>{amd(o.basePrice)}</span>
@@ -112,15 +109,9 @@ export function OrderDetail({ id }: { id: string }) {
<span>−{amd(o.discountAmount)}</span>
</div>
)}
{o.pointsUsed > 0 && (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{t('client.points.paidLine')}</span>
<span>−{amd(o.pointsUsed)}</span>
</div>
)}
<div className="bc-between total">
<span>{t('client.order.total')}</span>
<span>{amd(o.totalPrice - o.pointsUsed)}</span>
<span>{amd(o.totalPrice)}</span>
</div>
{o.cancelFee > 0 && (
<div className="bc-between">
@@ -132,31 +123,16 @@ export function OrderDetail({ id }: { id: string }) {
{t(`payment.${o.paymentStatus}`)}
{o.paymentMethod ? ` · ${o.paymentMethod.name[locale] || o.paymentMethod.name.ru}` : ''}
</div>
</section>
</Card>
{o.note && (
<section className="panel">
<Card tight>
<div className="bc-small bc-muted">{t('client.order.note')}</div>
<div>{o.note}</div>
</section>
</Card>
)}
<div className="bc-stack" style={{ gap: 8 }}>
{!o.active && o.status !== 'CANCELLED' && (
<Button variant="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
<Icon name="repeat" size={18} />
{t('client.detail.repeat')}
</Button>
)}
{o.status === 'CANCELLED' && (
<Button variant="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
{t('client.detail.orderAgain')}
</Button>
)}
<Button variant="light" block onClick={() => navigate('/chat')}>
<Icon name="chat" size={18} />
{t('client.detail.ask')}
</Button>
{o.active && preview.data?.canReschedule && (
<Button variant="light" block onClick={() => setReschedule(true)}>
{t('client.detail.reschedule')}
@@ -172,6 +148,17 @@ export function OrderDetail({ id }: { id: string }) {
{t('client.detail.viaOperator', { hours: preview.data.freeHours })}
</p>
)}
{!o.active && o.status !== 'CANCELLED' && (
<Button variant="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
<Icon name="repeat" size={18} />
{t('client.detail.repeat')}
</Button>
)}
{o.status === 'CANCELLED' && (
<Button variant="light" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
{t('client.detail.orderAgain')}
</Button>
)}
</div>
<RescheduleSheet open={reschedule} onClose={() => setReschedule(false)} order={o} />
@@ -179,76 +166,20 @@ export function OrderDetail({ id }: { id: string }) {
);
}
/** Оценка после выполнения: звёзды и комментарий; уже оценённый заказ показывает оценку с кнопкой «Изменить» */
function RateBlock({ id, rating }: { id: string; rating: { score: number; comment: string | null } | null }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const { query } = useLocation();
const ref = useRef<HTMLElement>(null);
const [editing, setEditing] = useState(!rating);
const [score, setScore] = useState(rating?.score ?? 0);
const [comment, setComment] = useState(rating?.comment ?? '');
useEffect(() => {
if (query.get('rate') && ref.current) ref.current.scrollIntoView({ block: 'center' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rate = useMutation(
trpc.client.orders.rate.mutationOptions({
onSuccess: () => {
haptic.ok();
toast(t('client.rate.thanks'));
setEditing(false);
void qc.invalidateQueries({ queryKey: trpc.client.orders.pathKey() });
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
if (!editing && rating)
return (
<section className="rate rate--done" ref={ref}>
<div className="bc-between">
<span className="rate__label">{t('client.rate.yours')}</span>
<button type="button" className="rate__change" onClick={() => setEditing(true)}>
{t('client.rate.change')}
</button>
</div>
<Stars value={rating.score} size={26} />
{rating.comment && <p className="rate__comment">{rating.comment}</p>}
</section>
);
return (
<section className="rate" ref={ref}>
<h2 className="rate__title">{t('client.rate.title')}</h2>
<p className="rate__text">{t('client.rate.text')}</p>
<Stars value={score} onChange={setScore} size={40} />
{score > 0 && (
<>
<textarea className="rate__input" rows={2} maxLength={1000} placeholder={t('client.rate.comment')} value={comment} onChange={(e) => setComment(e.target.value)} />
<button type="button" className="pill pill--pink pill--block" disabled={rate.isPending} onClick={() => rate.mutate({ id, score, comment: comment.trim() || undefined })}>
{t('client.rate.send')}
</button>
</>
)}
</section>
);
}
function PhotoReport({ photos }: { photos: { code: string; fileId: string }[] }) {
const { t } = useI18n();
const trpc = useTRPC();
return (
<section className="panel">
<h2 className="panel__title">{t('client.detail.photoReport')}</h2>
<Card>
<h2 className="bc-h3" style={{ marginBottom: 10 }}>
{t('client.detail.photoReport')}
</h2>
<div className="photos">
{photos.map((p) => (
<Photo key={p.fileId} fileId={p.fileId} alt={t(`checklist.${p.code}`)} trpc={trpc} />
))}
</div>
</section>
</Card>
);
}
+6 -96
View File
@@ -1,7 +1,7 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ParamField, ParamValues } from '@barev/core';
import { Button, Card, Center, Chip, Field, Icon, Spinner, Stepper } from '@barev/ui';
import { tOptional, dateKeyToDate, errorText, haptic, navigate, nextDays, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
import { dateKeyToDate, errorText, haptic, navigate, nextDays, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useMemo, useState } from 'react';
import { PageHead } from '../lib.tsx';
@@ -27,12 +27,6 @@ export function OrderForm({ code }: { code: string }) {
const [address, setAddress] = useState('');
const [note, setNote] = useState('');
const [methodId, setMethodId] = useState('');
// промокод: что ввёл клиент и что применено к расчёту
const [promoInput, setPromoInput] = useState('');
const [promo, setPromo] = useState('');
const [promoError, setPromoError] = useState<string | null>(null);
// барики: списать в этом заказе (с промокодом нельзя)
const [usePts, setUsePts] = useState(false);
const [touched, setTouched] = useState(false);
// дефолты услуги, затем — параметры повторяемого заказа (ORD-7)
@@ -56,18 +50,10 @@ export function OrderForm({ code }: { code: string }) {
}, [days, day]);
const quote = useQuery({
...trpc.client.quote.queryOptions({ serviceCode: code, params: params ?? {}, promoCode: promo || undefined }),
...trpc.client.quote.queryOptions({ serviceCode: code, params: params ?? {} }),
enabled: !!params,
placeholderData: keepPreviousData,
retry: false,
});
// неподходящий код — показываем причину и считаем без него
useEffect(() => {
if (promo && quote.error) {
setPromoError(errorText(t, quote.error));
setPromo('');
}
}, [promo, quote.error, t]);
const slots = useQuery({
...trpc.client.slots.queryOptions({ serviceCode: code, params: params ?? {}, dateKey: day ?? '' }),
enabled: !!params && !!day,
@@ -133,19 +119,15 @@ export function OrderForm({ code }: { code: string }) {
note: note.trim() || undefined,
paymentMethodId: methodId || undefined,
repeatOfId: repeatId ?? undefined,
promoCode: q?.promo ? promo : undefined,
usePoints: ptsUsed || undefined,
});
}
const q = quote.data;
const ptsAvailable = !!q?.points.allowed && !q.promo;
const ptsUsed = usePts && ptsAvailable ? q!.points.max : 0;
return (
<main className="app app--plain">
<PageHead title={t(`services.${code}.name`)} />
<p className="bc-muted" style={{ margin: '-4px 0 0' }}>
{tOptional(t, `services.${code}.description`)}
{t(`services.${code}.description`)}
</p>
{/* параметры услуги */}
@@ -285,7 +267,7 @@ export function OrderForm({ code }: { code: string }) {
</div>
{q.discountAmount > 0 ? (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{q.promo ? t('client.order.promoLine', { code: q.promo.code, pct: q.discountPct }) : t('client.order.discount', { pct: q.discountPct })}</span>
<span>{t('client.order.discount', { pct: q.discountPct })}</span>
<span>−{amd(q.discountAmount)}</span>
</div>
) : (
@@ -299,46 +281,6 @@ export function OrderForm({ code }: { code: string }) {
) : (
<Spinner />
)}
{q && q.points.balance > 0 && (
<label className="points-toggle" data-off={!ptsAvailable || undefined}>
<input type="checkbox" checked={usePts && ptsAvailable} disabled={!ptsAvailable} onChange={(e) => setUsePts(e.target.checked)} />
<span>
<b>{t('client.points.useInOrder', { amount: amd(q.points.max).replace(/\s?֏$/, '') })}</b>
<small>
{q.promo
? t('client.points.notWithPromo')
: !q.points.allowed
? t('client.points.belowMin', { min: amd(q.points.min).replace(/\s?֏$/, ''), balance: amd(q.points.balance).replace(/\s?֏$/, '') })
: t('client.points.useHint', { pct: q.points.sharePct, balance: amd(q.points.balance).replace(/\s?֏$/, '') })}
</small>
</span>
</label>
)}
{ptsUsed > 0 && (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{t('client.points.paidLine')}</span>
<span>−{amd(ptsUsed)}</span>
</div>
)}
<PromoField
value={promoInput}
onChange={(v) => {
setPromoInput(v);
setPromoError(null);
}}
applied={promo}
active={!!q?.promo}
error={promoError}
loading={quote.isFetching}
onApply={() => {
setPromoError(null);
setPromo(promoInput.trim());
}}
onRemove={() => {
setPromo('');
setPromoInput('');
}}
/>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('client.order.cancelPolicy', { hours: me.data?.freeCancelHours ?? 24, pct: s.lateCancelFeePct })}
</p>
@@ -351,7 +293,7 @@ export function OrderForm({ code }: { code: string }) {
{t('client.order.total')}
</span>
<span className="bc-num" style={{ fontSize: 22 }}>
{q ? amd(q.totalPrice - ptsUsed) : '…'}
{q ? amd(q.totalPrice) : '…'}
</span>
</div>
{slot && (
@@ -393,7 +335,7 @@ function ParamInput({
<button key={o} type="button" className="option" role="radio" aria-checked={value === o} aria-pressed={value === o} onClick={() => onChange(o)}>
<span style={{ fontWeight: 700 }}>{t(`params.${serviceCode}.${field.key}.${o}`)}</span>
<span className="bc-small bc-muted">
{prices?.[o] !== undefined ? t('common.from', { price: amd(prices[o]!) }) : tOptional(t, `params.${serviceCode}.${field.key}.${o}_hint`)}
{prices?.[o] !== undefined ? t('common.from', { price: amd(prices[o]!) }) : t(`params.${serviceCode}.${field.key}.${o}_hint`)}
</span>
</button>
))}
@@ -472,35 +414,3 @@ function ExtrasPicker({
</Card>
);
}
/** Промокод: применённый — плашкой с кнопкой «Убрать»; если скидка подписки больше — так и пишем */
function PromoField(p: { value: string; onChange: (v: string) => void; applied: string; active: boolean; error: string | null; loading: boolean; onApply: () => void; onRemove: () => void }) {
const { t } = useI18n();
if (p.applied && !p.loading)
return (
<div className="promo-applied">
<span>
<b>{p.applied.toUpperCase()}</b>
{!p.active && <span className="bc-small">{t('client.order.promoWeaker')}</span>}
</span>
<button type="button" onClick={p.onRemove}>
{t('client.order.promoRemove')}
</button>
</div>
);
return (
<form
className="promo-form"
onSubmit={(e) => {
e.preventDefault();
if (p.value.trim().length >= 2) p.onApply();
}}
>
<input className="bc-input" value={p.value} maxLength={40} autoCapitalize="characters" placeholder={t('client.order.promoPlaceholder')} aria-label={t('client.order.promo')} onChange={(e) => p.onChange(e.target.value.toUpperCase())} />
<button type="submit" className="pill pill--dark" disabled={p.value.trim().length < 2 || p.loading}>
{t('client.order.promoApply')}
</button>
{p.error && <div className="bc-error promo-form__error">{p.error}</div>}
</form>
);
}
+7 -14
View File
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { Center, Icon, Segmented, Spinner } from '@barev/ui';
import { Center, LinkButton, Segmented, Spinner } from '@barev/ui';
import { useI18n, useTRPC } from '@barev/web';
import { useState } from 'react';
import { OrderRow } from '../lib.tsx';
import { OrderRow, PageHead } from '../lib.tsx';
export function Orders() {
const { t } = useI18n();
@@ -18,12 +18,7 @@ export function Orders() {
const list = orders.data[tab];
return (
<main className="app">
<header className="screen-head">
<h1 className="screen-head__title">{t('client.orders.title')}</h1>
<a href="#/services" className="pill pill--dark" aria-label={t('client.orders.order')}>
<Icon name="plus" size={18} strokeWidth={2.6} />
</a>
</header>
<PageHead title={t('client.orders.title')} />
<Segmented
value={tab}
onChange={setTab}
@@ -33,14 +28,12 @@ export function Orders() {
]}
/>
{list.length === 0 ? (
<div className="empty-now">
<p>{t(tab === 'active' ? 'client.home.noOrders' : 'client.orders.noHistory')}</p>
<a href="#/services" className="pill pill--dark">
{t('client.orders.order')}
</a>
<div className="bc-stack" style={{ alignItems: 'center', padding: 24 }}>
<p className="bc-muted">{t(tab === 'active' ? 'client.home.noOrders' : 'client.orders.noHistory')}</p>
<LinkButton href="#/services">{t('client.orders.order')}</LinkButton>
</div>
) : (
<div className="order-list">
<div className="bc-stack" style={{ gap: 8 }}>
{list.map((o) => (
<OrderRow key={o.id} o={o} />
))}
+17 -23
View File
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { Card, Icon } from '@barev/ui';
import { Card } from '@barev/ui';
import { useI18n, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import { LEVEL_NAME, type Level } from '../lib.tsx';
import { LEVEL_NAME, LEVEL_TONE, PageHead, type Level } from '../lib.tsx';
import { Qr } from '../qr.tsx';
/**
@@ -16,33 +16,27 @@ export function Perks() {
const qr = useQuery({ ...trpc.client.cardQr.queryOptions(), refetchInterval: 20_000, refetchIntervalInBackground: false });
const partners = useQuery(trpc.client.partners.queryOptions());
const level = (me.data?.membership.level ?? 'BAREV') as Level;
const tone = LEVEL_TONE[level];
const left = useSecondsLeft(qr.data?.expiresAt);
const name = (l: Record<string, string>) => l[locale] || l.ru || '';
return (
<main className="app app--hero">
<section className="hero hero--card">
<h1 className="hero__hello">{t('client.card.title')}</h1>
<div className="qr-card" data-plan={level === 'BAREV' ? 'free' : level === 'BAREV_PLUS' ? 'plus' : 'elite'}>
<div className="qr-card__top">
<span>Barev Club</span>
<b>{LEVEL_NAME[level]}</b>
</div>
<div className="qr-card__code">{qr.data ? <Qr value={qr.data.url} size={200} label={t('client.partners.qrLabel')} /> : <div style={{ width: 200, height: 200 }} />}</div>
<div className="qr-card__meta">
<span>
<b>{me.data?.firstName}</b> · <span className="bc-num">{me.data?.cardCode}</span>
</span>
<span className="qr-card__timer">{t('client.partners.qrRefresh', { seconds: left })}</span>
</div>
<main className="app">
<PageHead title={t('client.perks.title')} back={false} />
<section className="card-qr" style={{ background: level === 'BAREV' ? 'var(--bc-plum)' : tone.bg, color: level === 'BAREV' ? '#fff' : tone.ink }}>
<div className="card-qr__top">
<span>Barev Club · {LEVEL_NAME[level]}</span>
<b>{me.data?.firstName}</b>
</div>
<div className="card-qr__code">{qr.data ? <Qr value={qr.data.url} size={208} label={t('client.partners.qrLabel')} /> : <div style={{ width: 208, height: 208 }} />}</div>
<div className="card-qr__meta">
<span className="bc-num">{me.data?.cardCode}</span>
<span>{t('client.partners.qrRefresh', { seconds: left })}</span>
</div>
<p className="hero__note">{t('client.partners.howTo')}</p>
<a href="#/membership" className="pill pill--pink">
{t('client.card.plans')}
<Icon name="arrow" size={18} />
</a>
</section>
<h2 className="sect__title">{t('client.card.partners')}</h2>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('client.partners.howTo')}
</p>
{partners.data?.length === 0 ? (
<Card tone="perks" className="bc-stack">
<h2 className="bc-h3">{t('client.perks.soonTitle')}</h2>
-213
View File
@@ -1,213 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Badge, Button, Card, Center, Field, Icon, Sheet, Spinner } from '@barev/ui';
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { LEVEL_NAME, PageHead, type Level } from '../lib.tsx';
/** Экран «Барики»: баланс, ожидание, сгорание, ставки по уровню, серия уборок, маршрут, история */
export function Points() {
const { t, dt, locale } = useI18n();
const trpc = useTRPC();
const p = useQuery(trpc.client.points.summary.queryOptions());
const history = useQuery(trpc.client.points.history.queryOptions());
const [pay, setPay] = useState(false);
const n = (v: number) => new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale).format(v);
const date = (d: Date) => dt(d, { day: 'numeric', month: 'long' });
const name = (m: Record<string, string> | null | undefined) => (m ? m[locale] || m.ru || m.en || '' : '');
if (!p.data)
return (
<Center>
<Spinner />
</Center>
);
const d = p.data;
const tier = d.tier as Level;
return (
<main className="app app--plain">
<PageHead title={t('client.points.title')} />
<Card tone="dark" className="bc-stack points-hero">
<span className="bc-small" style={{ opacity: 0.75 }}>
{t('client.points.balance')}
</span>
<div className="points-hero__num">{n(d.balance)}</div>
<span className="bc-small" style={{ opacity: 0.85 }}>
{t('client.points.equals', { amount: n(Math.max(0, d.balance)) })}
</span>
{d.pending > 0 && <span className="bc-small">⏳ {t('client.points.pending', { amount: n(d.pending), date: d.pendingNextAt ? date(d.pendingNextAt) : '—' })}</span>}
{d.burnAt && <span className="bc-small">🔥 {t('client.points.burn', { amount: n(d.burnAmount), date: date(d.burnAt) })}</span>}
<div className="bc-row" style={{ gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
<Button size="sm" variant="light" disabled={d.balance < d.redeem.min} onClick={() => setPay(true)}>
{t('client.points.pay')}
</Button>
<a className="bc-btn bc-btn--outline bc-btn--sm points-hero__ghost" href="#/profile?code=1">
{t('client.points.enterCode')}
</a>
</div>
{d.openRequests > 0 && <span className="bc-small">{t('client.points.requestPending')}</span>}
</Card>
<Card className="bc-stack">
<div className="bc-between">
<h2 className="bc-h3">{t('client.points.rates')}</h2>
<Badge>{LEVEL_NAME[tier]}</Badge>
</div>
<table className="points-rates">
<thead>
<tr>
<th />
<th data-on={tier === 'BAREV' || undefined}>Barev</th>
<th data-on={tier === 'BAREV_PLUS' || undefined}>Barev+</th>
<th data-on={tier === 'ELITE' || undefined}>Elite</th>
</tr>
</thead>
<tbody>
{d.rates.map((r) => (
<tr key={r.code}>
<td>{name(r.name)}</td>
<td data-on={tier === 'BAREV' || undefined}>{r.barev}%</td>
<td data-on={tier === 'BAREV_PLUS' || undefined}>{r.plus}%</td>
<td data-on={tier === 'ELITE' || undefined}>{r.elite}%</td>
</tr>
))}
</tbody>
</table>
{tier === 'BAREV' && d.plusWouldGet > 0 && (
<a href="#/membership" className="bc-small" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
{t('client.points.plusWouldGet', { amount: n(d.plusWouldGet) })}
</a>
)}
</Card>
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.points.streak')}</h2>
<div className="points-dots" aria-hidden="true">
{[1, 2, 3, 4, 5, 6].map((i) => (
<span key={i} data-on={d.streak.count >= i || undefined} data-mark={i === 3 || i === 6 || undefined} />
))}
</div>
<p className="bc-small" style={{ margin: 0 }}>
{d.streak.next
? t('client.points.streakLeft', { left: d.streak.next - d.streak.count, bonus: d.streak.next === 3 ? 2 : 3, days: d.streak.gapDays })
: t('client.points.streakMax', { bonus: d.streak.bonusPct })}
</p>
</Card>
{d.route && (
<Card tone="home" className="bc-stack">
<h2 className="bc-h3">{t('client.points.route')}</h2>
<div className="points-dots" aria-hidden="true">
{[1, 2, 3].map((i) => (
<span key={i} data-on={d.route!.done >= i || undefined} />
))}
</div>
<p className="bc-small" style={{ margin: 0 }}>
{t('client.points.routeText', { done: d.route.done, amount: n(d.route.amount), date: date(d.route.until) })}
</p>
</Card>
)}
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('client.points.history')}</h2>
{!history.data ? (
<Spinner />
) : history.data.length === 0 ? (
<p className="bc-muted" style={{ margin: 0 }}>
{t('client.points.empty')}
</p>
) : (
<Card tight className="bc-stack" style={{ gap: 0 }}>
{history.data.map((r) => (
<div key={`${r.type}:${r.id}`} className="points-row">
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 600 }}>
{r.type === 'bonus' ? name(r.bonus) : r.type === 'accrual' ? name(r.category) : t(`client.points.type.${r.type}`)}
{r.orderRef ? <span className="bc-muted"> {r.orderRef}</span> : null}
</div>
<div className="bc-small bc-muted">
{dt(r.at, { day: 'numeric', month: 'short' })} · {t(`client.points.status.${r.status}`)}
{r.status === 'PENDING' && r.availableAt ? ` · ${t('client.points.availableFrom', { date: date(r.availableAt) })}` : ''}
{r.expiresAt && r.status === 'ACTIVE' ? ` · ${t('client.points.until', { date: date(r.expiresAt) })}` : ''}
</div>
{r.comment && <div className="bc-small">💬 {r.comment}</div>}
</div>
<b className={r.amount > 0 ? 'points-plus' : 'points-minus'}>
{r.amount > 0 ? '+' : '−'}
{n(Math.abs(r.amount))}
</b>
</div>
))}
</Card>
)}
</section>
{pay && <PaySheet balance={d.balance} sharePct={d.redeem.sharePct} min={d.redeem.min} onClose={() => setPay(false)} />}
</main>
);
}
/** Оплатить бариками: выбрать заказ или ввести сумму — запрос уходит команде */
function PaySheet({ balance, sharePct, min, onClose }: { balance: number; sharePct: number; min: number; onClose: () => void }) {
const { t, amd, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const orders = useQuery(trpc.client.orders.list.queryOptions());
const [orderId, setOrderId] = useState<string>('');
const [amount, setAmount] = useState('');
const [note, setNote] = useState('');
const order = orders.data?.active.find((o) => o.id === orderId);
const max = order ? Math.min(balance, Math.floor(((order.totalPrice - order.pointsUsed) * sharePct) / 100)) : balance;
const value = Number(amount) || 0;
const send = useMutation(
trpc.client.points.request.mutationOptions({
onSuccess: () => {
haptic.ok();
toast(t('client.points.requested'));
void qc.invalidateQueries();
onClose();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const payable = orders.data?.active.filter((o) => o.paymentStatus === 'UNPAID') ?? [];
return (
<Sheet open onClose={onClose} label={t('client.points.pay')}>
<div className="bc-stack">
<div className="bc-between">
<h2 className="bc-h2">{t('client.points.pay')}</h2>
<button className="back" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" />
</button>
</div>
<p className="bc-small" style={{ margin: 0 }}>
{t('client.points.payHint', { pct: sharePct, min: amd(min).replace(/\s?֏$/, '') })}
</p>
<Field label={t('client.points.forOrder')}>
<select className="bc-input" value={orderId} onChange={(e) => setOrderId(e.target.value)}>
<option value="">{t('client.points.otherOrder')}</option>
{payable.map((o) => (
<option key={o.id} value={o.id}>
#{o.number} · {t(`services.${o.serviceCode}.name`)} · {dt(o.scheduledAt, { day: 'numeric', month: 'short' })}
</option>
))}
</select>
</Field>
<Field label={t('client.points.amount')}>
<input className="bc-input" inputMode="numeric" value={amount} placeholder={String(max)} onChange={(e) => setAmount(e.target.value.replace(/\D/g, '').slice(0, 7))} />
</Field>
<p className="bc-small bc-muted" style={{ margin: '-6px 0 0' }}>
{t('client.points.max', { max: amd(max).replace(/\s?֏$/, '') })}
</p>
{!order && (
<Field label={t('client.points.note')}>
<input className="bc-input" value={note} maxLength={300} placeholder={t('client.points.notePh')} onChange={(e) => setNote(e.target.value)} />
</Field>
)}
<Button block disabled={send.isPending || value < min || value > max} onClick={() => send.mutate({ amount: value, orderId: orderId || undefined, note: note.trim() || undefined })}>
{t('client.points.send')}
</Button>
</div>
</Sheet>
);
}
+20 -44
View File
@@ -3,7 +3,7 @@ import { LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
import { Button, Card, Chip, Field, Icon, Segmented } from '@barev/ui';
import { errorText, haptic, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { ClubTicket, LEVEL_NAME, type Level } from '../lib.tsx';
import { LEVEL_NAME, LevelBadge, PageHead, type Level } from '../lib.tsx';
export function Profile() {
const { t, dt } = useI18n();
@@ -14,26 +14,21 @@ export function Profile() {
const plus = level !== 'BAREV';
return (
<main className="app app--hero">
<section className="hero">
<h1 className="hero__hello">
<small>{t('client.profile.title')}</small>
<span>{me.data?.firstName}</span>
</h1>
<p className="hero__note">{me.data?.agencyName ? t('client.profile.fromAgency', { agency: me.data.agencyName }) : t('client.profile.noAgency')}</p>
<ClubTicket
level={level}
code={me.data?.cardCode}
href="#/membership"
until={plus && me.data?.membership.endsAt ? t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long', year: 'numeric' }) }) : t('client.home.free')}
action={
<>
<Icon name="star" size={26} />
<span>{t('client.card.plans')}</span>
</>
}
/>
</section>
<main className="app">
<PageHead title={t('client.profile.title')} back={false} />
<Card className="bc-stack">
<div className="bc-between">
<div>
<div className="bc-h3">{me.data?.firstName}</div>
<div className="bc-small bc-muted">{me.data?.agencyName ? t('client.profile.fromAgency', { agency: me.data.agencyName }) : t('client.profile.noAgency')}</div>
</div>
<LevelBadge level={level} />
</div>
{plus && me.data?.membership.endsAt && <div className="bc-small">{t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long', year: 'numeric' }) })}</div>}
<a href="#/membership" className="bc-row" style={{ fontWeight: 600, color: 'var(--bc-accent)', textDecoration: 'none' }}>
{t('client.profile.membership')} <Icon name="arrow" size={16} />
</a>
</Card>
<SegmentSwitch current={me.data?.segment ?? null} />
@@ -41,31 +36,12 @@ export function Profile() {
<LanguagePicker />
<nav className="links">
<a href="#/points">
<Icon name="star" size={20} />
<span>
{t('client.points.title')}
{me.data ? <b style={{ marginLeft: 8 }}>{new Intl.NumberFormat('ru-RU').format(me.data.points)}</b> : null}
</span>
<Card tight>
<a href="#/orders" className="bc-between" style={{ textDecoration: 'none', minHeight: 44 }}>
<span style={{ fontWeight: 600 }}>{t('client.orders.title')}</span>
<Icon name="arrow" size={18} />
</a>
<a href="#/orders">
<Icon name="clock" size={20} />
<span>{t('client.orders.title')}</span>
<Icon name="arrow" size={18} />
</a>
<a href="#/perks">
<Icon name="gift" size={20} />
<span>{t('client.card.title')}</span>
<Icon name="arrow" size={18} />
</a>
<a href="#/chat">
<Icon name="chat" size={20} />
<span>{t('client.nav.chat')}</span>
<Icon name="arrow" size={18} />
</a>
</nav>
</Card>
</main>
);
}
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Field, Icon } from '@barev/ui';
import { tOptional, errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { levelDiscount, PageHead, toneClass, type Level } from '../lib.tsx';
@@ -31,7 +31,7 @@ export function ServiceRequest({ code }: { code: string }) {
<main className="app app--plain">
<PageHead title={t(`services.${code}.name`)} />
<Card className={`bc-stack ${category ? toneClass(category.code) : ''}`}>
<p style={{ margin: 0 }}>{tOptional(t, `services.${code}.description`)}</p>
<p style={{ margin: 0 }}>{t(`services.${code}.description`)}</p>
{pct > 0 && <div style={{ fontWeight: 700 }}>{t('client.request.yourDiscount', { pct })}</div>}
</Card>
{sent ? (
+2 -4
View File
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { CATEGORY_ICON, Center, Icon, Spinner, type CategoryCode } from '@barev/ui';
import { useI18n, useLocation, useTRPC } from '@barev/web';
import { useEffect } from 'react';
import { levelDiscount, toneClass, type Level } from '../lib.tsx';
import { levelDiscount, PageHead, toneClass, type Level } from '../lib.tsx';
/** Каталог: направления со скидкой по уровню клиента; услуги без онлайн-записи — заявка «перезвоните» */
export function Services() {
@@ -25,9 +25,7 @@ export function Services() {
const level = catalog.data.level as Level;
return (
<main className="app">
<header className="screen-head">
<h1 className="screen-head__title">{t('client.services.title')}</h1>
</header>
<PageHead title={t('client.services.title')} back={false} />
{catalog.data.categories
.filter((c) => c.code !== 'PERKS')
.map((c) => (
+1 -3
View File
@@ -2,14 +2,12 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
// API_PROXY — другой локальный API (например, второй экземпляр на соседнем порту)
const api = process.env.API_PROXY ?? 'http://localhost:4000';
export default defineConfig({
plugins: [react()],
server: {
host: true,
allowedHosts: true,
proxy: { '/trpc': api, '/files': api, '/client-log': api },
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
},
build: { target: 'es2022', sourcemap: true },
});
+1 -6
View File
@@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query';
import { Center, Icon, Spinner } from '@barev/ui';
import { I18nProvider, match, navigate, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
import { useEffect, type ReactNode } from 'react';
import { forgetWebLogin, isWeb } from './auth.ts';
import { Company } from './screens/Company.tsx';
import { History } from './screens/History.tsx';
import { Job } from './screens/Job.tsx';
@@ -16,14 +15,10 @@ export function App({ hasInitData }: { hasInitData: boolean }) {
const me = useQuery({ ...trpc.pro.me.queryOptions(), enabled: hasInitData });
const locale = pickLocale(me.data?.locale, telegramLanguage(), navigator.language);
const status = me.error?.data?.httpStatus;
useEffect(() => {
if (status === 401 && isWeb()) forgetWebLogin();
}, [status]);
return (
<I18nProvider initial={locale}>
{!hasInitData || status === 401 ? (
// в браузере без входа или с устаревшим входом — подсказка, как получить ссылку из бота
<Message titleKey="pro.openInTelegram.title" textKey={isWeb() ? 'pro.web.loginHint' : 'pro.openInTelegram.text'} />
<Message titleKey="pro.openInTelegram.title" textKey="pro.openInTelegram.text" />
) : status === 403 ? (
<Message titleKey="pro.notLinked.title" textKey="pro.notLinked.text" />
) : me.isPending ? (
-57
View File
@@ -203,60 +203,3 @@
.invite .bc-field {
width: 100%;
}
/* кабинет менеджера партнёра: в браузере на компьютере — шире, карточки в две колонки */
.app--wide {
max-width: 1040px;
padding-bottom: 40px;
}
.cab-grid {
display: grid;
gap: 8px;
}
@media (min-width: 820px) {
.app--wide .cab-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.cab-row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.cab-link {
border: none;
background: none;
padding: 6px 0;
color: var(--bc-plum);
font-weight: 700;
cursor: pointer;
font: inherit;
}
.cab-copy {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 8px 8px 12px;
border-radius: 16px;
background: var(--bc-bg);
}
.cab-copy code {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.cab-switch {
display: flex;
gap: 12px;
align-items: flex-start;
cursor: pointer;
}
.cab-switch input {
width: 22px;
height: 22px;
margin-top: 2px;
accent-color: var(--bc-plum);
}
+2 -51
View File
@@ -1,54 +1,5 @@
import { getInitData } from '@barev/web';
/**
* Внутри Telegram запросы подписаны initData. В браузере (кабинет менеджера партнёра на компьютере)
* — токен: бот выдаёт одноразовую ссылку ?login=<код>, страница меняет её на токен и хранит в localStorage.
*/
const initData = getInitData();
const TOKEN_KEY = 'barev.pro.web';
function readToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
let webToken = initData ? null : readToken();
export const hasAuth = () => !!initData || !!webToken;
export const isWeb = () => !initData;
export const authHeader = (): Record<string, string> => (initData ? { authorization: `tma ${initData}` } : webToken ? { authorization: `pro ${webToken}` } : {});
export function forgetWebLogin() {
webToken = null;
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* приватный режим */
}
}
/** Ссылка из бота: обменять одноразовый код на токен до первого запроса */
export async function consumeLoginLink(): Promise<void> {
if (initData) return;
const url = new URL(window.location.href);
const code = url.searchParams.get('login');
if (!code) return;
url.searchParams.delete('login');
window.history.replaceState(null, '', url.toString());
try {
const res = await fetch('/trpc/pro.webLogin', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ json: { code } }) });
const body = (await res.json()) as { result?: { data?: { json?: { token: string } } } };
const token = body.result?.data?.json?.token;
if (!token) return;
webToken = token;
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* без localStorage вход действует до закрытия вкладки */
}
} catch {
/* сеть — покажем экран «откройте ссылку из бота» */
}
}
export const hasInitData = !!initData;
export const authHeader = () => ({ authorization: `tma ${initData}` });
+11 -16
View File
@@ -1,24 +1,19 @@
import '@barev/ui/styles.css';
import './app.css';
import { ApiProvider, loadTextOverrides, initTelegram, ToastProvider } from '@barev/web';
import { ApiProvider, initTelegram, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import { authHeader, consumeLoginLink, hasAuth } from './auth.ts';
import { authHeader, hasInitData } from './auth.ts';
initTelegram();
// названия и описания услуг, изменённые в админке
void loadTextOverrides();
// в браузере: ссылка из бота ?login=… меняется на токен до первого запроса
void consumeLoginLink().then(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ApiProvider headers={authHeader}>
<ToastProvider>
<App hasInitData={hasAuth()} />
</ToastProvider>
</ApiProvider>
</StrictMode>,
);
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ApiProvider headers={authHeader}>
<ToastProvider>
<App hasInitData={hasInitData} />
</ToastProvider>
</ApiProvider>
</StrictMode>,
);
+84 -564
View File
@@ -1,24 +1,20 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { LOCALES, type Locale } from '@barev/i18n';
import { Badge, Button, Card, Center, Chip, Field, Icon, Segmented, Sheet, Spinner } from '@barev/ui';
import { confirmDialog, errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState, type ReactNode } from 'react';
import { forgetWebLogin, isWeb } from '../auth.ts';
import { Badge, Button, Card, Center, Chip, Icon, Sheet, Spinner } from '@barev/ui';
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { JobRow, PageHead } from '../lib.tsx';
type Tab = 'orders' | 'staff' | 'money' | 'settings';
type StaffCheck = 'ok' | 'busy' | 'gap' | 'limit' | 'notLinked';
/** Кабинет менеджера партнёра: заказы и распределение, сотрудники, доход, настройки */
/** PRO-7: диспетчер компании распределяет заказы, назначенные на компанию */
export function Company({ companies }: { companies: { id: string; name: string }[] }) {
const { t } = useI18n();
const trpc = useTRPC();
const [companyId, setCompanyId] = useState(companies[0]!.id);
const [tab, setTab] = useState<Tab>('orders');
const cab = useCabinet(companyId);
const board = useQuery({ ...trpc.pro.dispatcher.board.queryOptions({ companyId }), refetchInterval: 15_000 });
const [picking, setPicking] = useState<string | null>(null);
return (
<main className={isWeb() ? 'app app--wide' : 'app'}>
<PageHead title={cab.data?.company.name ?? t('pro.company.title')} back={false} />
<main className="app">
<PageHead title={board.data?.company.name ?? t('pro.company.title')} back={false} />
{companies.length > 1 && (
<div className="bc-chips">
{companies.map((c) => (
@@ -28,587 +24,111 @@ export function Company({ companies }: { companies: { id: string; name: string }
))}
</div>
)}
<Segmented
value={tab}
onChange={setTab}
label={t('pro.company.title')}
options={[
{ value: 'orders', label: t('pro.cab.tab.orders') + (cab.data?.pending.length ? ` · ${cab.data.pending.length}` : '') },
{ value: 'staff', label: t('pro.cab.tab.staff') },
{ value: 'money', label: t('pro.cab.tab.money') },
{ value: 'settings', label: t('pro.cab.tab.settings') },
]}
/>
{!cab.data ? (
{!board.data ? (
<Center>
<Spinner />
</Center>
) : tab === 'orders' ? (
<Orders companyId={companyId} data={cab.data} />
) : tab === 'staff' ? (
<Staff companyId={companyId} data={cab.data} />
) : tab === 'money' ? (
<Money data={cab.data} />
) : (
<Settings companyId={companyId} data={cab.data} />
<>
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">
{t('pro.company.pending')} · {board.data.pending.length}
</h2>
{board.data.pending.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noPending')}</p>}
{board.data.pending.map((o) => (
<Card key={o.id} tone="pink" tight className="bc-stack" style={{ gap: 8 }}>
<JobRow j={o} />
<Button size="sm" onClick={() => setPicking(o.id)}>
{t('pro.company.assign')}
</Button>
</Card>
))}
</section>
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.company.assigned')}</h2>
{board.data.assigned.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noAssigned')}</p>}
{board.data.assigned.map((o) => (
<JobRow key={o.id} j={o} right={<span className="bc-small" style={{ fontWeight: 600 }}>{o.providerName}</span>} />
))}
</section>
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.company.staff')}</h2>
<Card tight className="bc-stack" style={{ gap: 6 }}>
{board.data.staff.map((p) => (
<div key={p.id} className="bc-between" style={{ minHeight: 40 }}>
<span style={{ fontWeight: 600 }}>{p.name}</span>
<span className="bc-row bc-small">
{p.languages.map((l) => l.toUpperCase()).join(' ')}
<Badge style={{ background: p.onShift ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(p.onShift ? 'pro.today.onShift' : 'pro.today.offShift')}</Badge>
{!p.linked && <Badge style={{ background: 'var(--bc-docs)' }}>{t('pro.company.notLinked')}</Badge>}
</span>
</div>
))}
</Card>
</section>
</>
)}
{picking && <StaffSheet companyId={companyId} orderId={picking} onClose={() => setPicking(null)} />}
</main>
);
}
function useCabinet(companyId: string) {
const trpc = useTRPC();
return useQuery({ ...trpc.pro.dispatcher.cabinet.queryOptions({ companyId }), refetchInterval: 15_000 });
}
type Cabinet = NonNullable<ReturnType<typeof useCabinet>['data']>;
function useAction() {
function StaffSheet({ companyId, orderId, onClose }: { companyId: string; orderId: string; onClose: () => void }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
return {
onSuccess: (msg?: string) => () => {
haptic.ok();
if (msg) toast(msg);
void qc.invalidateQueries();
},
onError: (e: unknown) => toast(errorText(t, e), 'error'),
};
}
// ───── заказы ─────
function Orders({ companyId, data }: { companyId: string; data: Cabinet }) {
const { t, dt, amd } = useI18n();
const trpc = useTRPC();
const act = useAction();
const [picking, setPicking] = useState<string | null>(null);
const decline = useMutation(trpc.pro.dispatcher.decline.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.declined')), onError: act.onError }));
const unassign = useMutation(trpc.pro.dispatcher.unassign.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.unassigned')), onError: act.onError }));
return (
<>
{data.company.autoAssign && (
<Card tight tone="blue" className="bc-row" style={{ gap: 10 }}>
<Icon name="repeat" size={20} />
<span className="bc-small">{t('pro.cab.autoOn')}</span>
</Card>
)}
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">
{t('pro.company.pending')} · {data.pending.length}
</h2>
{data.pending.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noPending')}</p>}
<div className="cab-grid">
{data.pending.map((o) => (
<Card key={o.id} tone="pink" tight className="bc-stack" style={{ gap: 8 }}>
<JobRow j={o} right={<b className="bc-small">{dt(o.scheduledAt, { day: 'numeric', month: 'short' })}</b>} />
<div className="bc-small" style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
<span>
{t('pro.cab.income')}: <b>{amd(o.income)}</b>
</span>
{o.deadline && !o.offeredTo && <span>{t('pro.cab.deadline', { time: dt(o.deadline, { hour: '2-digit', minute: '2-digit' }) })}</span>}
{o.offeredTo && <span>{t('pro.cab.offeredTo', { name: o.offeredTo })}</span>}
</div>
<div className="bc-row" style={{ gap: 8 }}>
<Button size="sm" onClick={() => setPicking(o.id)}>
{t('pro.company.assign')}
</Button>
<Button
size="sm"
variant="ghost"
disabled={decline.isPending}
onClick={async () => {
if (await confirmDialog(t('pro.cab.declineConfirm'))) decline.mutate({ companyId, orderId: o.id });
}}
>
{t('pro.cab.decline')}
</Button>
</div>
</Card>
))}
</div>
</section>
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.company.assigned')}</h2>
{data.assigned.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noAssigned')}</p>}
<div className="cab-grid">
{data.assigned.map((o) => (
<Card key={o.id} tight className="bc-stack" style={{ gap: 6 }}>
<JobRow j={o} right={<b className="bc-small">{dt(o.scheduledAt, { day: 'numeric', month: 'short' })}</b>} />
<div className="bc-between bc-small">
<span>
<Icon name="user" size={16} /> <b>{o.providerName}</b> · {t(`status.${o.flow}.${o.status}`)}
</span>
{o.canUnassign && (
<button
type="button"
className="cab-link"
disabled={unassign.isPending}
onClick={async () => {
if (await confirmDialog(t('pro.cab.unassignConfirm', { name: o.providerName ?? '' }))) unassign.mutate({ companyId, orderId: o.id });
}}
>
{t('pro.cab.reassign')}
</button>
)}
</div>
</Card>
))}
</div>
</section>
{picking && <AssignSheet companyId={companyId} orderId={picking} onClose={() => setPicking(null)} />}
</>
);
}
const CHECK_TONE: Record<StaffCheck, string> = { ok: 'var(--bc-home)', notLinked: 'var(--bc-docs)', gap: 'var(--bc-docs)', limit: 'var(--bc-docs)', busy: 'var(--bc-bg)' };
function AssignSheet({ companyId, orderId, onClose }: { companyId: string; orderId: string; onClose: () => void }) {
const { t } = useI18n();
const trpc = useTRPC();
const act = useAction();
const staff = useQuery(trpc.pro.dispatcher.staffForOrder.queryOptions({ companyId, orderId }));
const assign = useMutation(
trpc.pro.dispatcher.assign.mutationOptions({
onSuccess: () => {
act.onSuccess(t('pro.company.assigned_ok'))();
haptic.ok();
toast(t('pro.company.assigned_ok'));
void qc.invalidateQueries();
onClose();
},
onError: act.onError,
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
// свободные и говорящие на языке клиента — выше
const sorted = [...(staff.data ?? [])].sort((a, b) => Number(b.free) - Number(a.free) || Number(b.speaksClientLanguage) - Number(a.speaksClientLanguage) || a.load - b.load);
return (
<Sheet open onClose={onClose} label={t('pro.company.assign')}>
<div className="bc-stack">
<SheetHead title={t('pro.company.chooseStaff')} onClose={onClose} />
{staff.data && <p className="bc-small bc-muted" style={{ margin: 0 }}>{t('pro.cab.gapHint', { min: staff.data.gap })}</p>}
<div className="bc-between">
<h2 className="bc-h2">{t('pro.company.chooseStaff')}</h2>
<button className="back" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" />
</button>
</div>
{!staff.data ? (
<Spinner />
) : staff.data.rows.length === 0 ? (
<p className="bc-muted">{t('pro.cab.noStaff')}</p>
) : (
staff.data.rows.map((p) => {
const can = p.check !== 'busy';
return (
<button
key={p.id}
type="button"
className="job"
style={{ border: 'none', textAlign: 'left', cursor: can ? 'pointer' : 'not-allowed', opacity: can ? 1 : 0.5 }}
disabled={!can || assign.isPending}
onClick={async () => {
if (p.check !== 'ok' && !(await confirmDialog(t(`pro.cab.check.${p.check}_confirm`)))) return;
assign.mutate({ companyId, orderId, providerId: p.id });
}}
>
<div className="job__main">
<div style={{ fontWeight: 700 }}>{p.name}</div>
<div className="bc-small bc-muted">
{p.languages.map((l) => l.toUpperCase()).join(' ')} · {t('pro.cab.dayLoad', { n: p.dayOrders, max: p.maxOrdersPerDay ?? '∞' })} · {t('pro.cab.weekLoad', { n: p.weekOrders })}
{p.rating != null && ` · ★ ${p.rating}`}
</div>
</div>
<span className="bc-row" style={{ flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{p.language && <Badge style={{ background: 'var(--bc-home)' }}>{t('pro.company.speaksLanguage')}</Badge>}
{p.district && <Badge style={{ background: 'var(--bc-blue)' }}>{t('pro.cab.district')}</Badge>}
<Badge style={{ background: CHECK_TONE[p.check] }}>{t(`pro.cab.check.${p.check}`)}</Badge>
</span>
</button>
);
})
)}
</div>
</Sheet>
);
}
function SheetHead({ title, onClose }: { title: ReactNode; onClose: () => void }) {
const { t } = useI18n();
return (
<div className="bc-between">
<h2 className="bc-h2">{title}</h2>
<button className="back" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" />
</button>
</div>
);
}
// ───── сотрудники ─────
type StaffRow = Cabinet['staff'][number];
function Staff({ companyId, data }: { companyId: string; data: Cabinet }) {
const { t } = useI18n();
const [edit, setEdit] = useState<StaffRow | 'new' | null>(null);
return (
<>
<div className="bc-between">
<h2 className="bc-h3">
{t('pro.company.staff')} · {data.staff.filter((s) => s.active).length}
</h2>
<Button size="sm" onClick={() => setEdit('new')}>
<Icon name="plus" size={18} /> {t('pro.cab.addStaff')}
</Button>
</div>
{data.staff.length === 0 && <p className="bc-muted">{t('pro.cab.noStaff')}</p>}
<div className="cab-grid">
{data.staff.map((p) => (
<button key={p.id} type="button" className="job" style={{ border: 'none', textAlign: 'left', cursor: 'pointer', opacity: p.active ? 1 : 0.5 }} onClick={() => setEdit(p)}>
<div className="job__main">
<div style={{ fontWeight: 700 }}>{p.name}</div>
<div className="bc-small bc-muted">
{p.languages.map((l) => l.toUpperCase()).join(' ')} · {t('pro.cab.todayLoad', { n: p.today, max: p.maxOrdersPerDay ?? '∞' })}
{p.maxHoursPerDay ? ` · ${t('pro.cab.hoursMax', { n: p.maxHoursPerDay })}` : ''}
</div>
</div>
<span className="bc-row">
{!p.active ? (
<Badge style={{ background: 'var(--bc-bg)' }}>{t('pro.cab.off')}</Badge>
) : !p.linked ? (
<Badge style={{ background: 'var(--bc-docs)' }}>{t('pro.company.notLinked')}</Badge>
) : (
<Badge style={{ background: p.onShift ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(p.onShift ? 'pro.today.onShift' : 'pro.today.offShift')}</Badge>
)}
</span>
</button>
))}
</div>
{edit && <StaffSheet companyId={companyId} districts={data.districts} staff={edit === 'new' ? null : edit} onClose={() => setEdit(null)} />}
</>
);
}
const DAYS = [0, 1, 2, 3, 4, 5, 6];
const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const toMin = (s: string) => {
const [h, m] = s.split(':').map(Number);
return (h ?? 0) * 60 + (m ?? 0);
};
function StaffSheet({ companyId, districts, staff, onClose }: { companyId: string; districts: { id: string; code: string }[]; staff: StaffRow | null; onClose: () => void }) {
const { t } = useI18n();
const trpc = useTRPC();
const act = useAction();
const first = staff?.schedule[0];
const [f, setF] = useState({
name: staff?.name ?? '',
phone: staff?.phone ?? '',
languages: (staff?.languages ?? ['ru']) as Locale[],
districtIds: staff?.districtIds ?? [],
days: staff ? [...new Set(staff.schedule.map((s) => s.weekday))] : [0, 1, 2, 3, 4, 5],
from: hhmm(first?.startMinute ?? 9 * 60),
to: hhmm(first?.endMinute ?? 19 * 60),
maxOrders: staff?.maxOrdersPerDay ? String(staff.maxOrdersPerDay) : '',
maxHours: staff?.maxHoursPerDay ? String(staff.maxHoursPerDay) : '',
});
const [link, setLink] = useState<string | null>(staff?.inviteLink ?? null);
const toggle = <T,>(list: T[], v: T) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
const payload = () => ({
name: f.name,
phone: f.phone || null,
languages: f.languages,
districtIds: f.districtIds,
schedule: f.days.map((weekday) => ({ weekday, startMinute: toMin(f.from), endMinute: toMin(f.to) })),
maxOrdersPerDay: Number(f.maxOrders) || null,
maxHoursPerDay: Number(f.maxHours) || null,
});
const add = useMutation(
trpc.pro.dispatcher.addStaff.mutationOptions({
onSuccess: (r) => {
act.onSuccess(t('pro.cab.staffAdded'))();
setLink(r.inviteLink);
},
onError: act.onError,
}),
);
const save = useMutation(
trpc.pro.dispatcher.updateStaff.mutationOptions({
onSuccess: () => {
act.onSuccess(t('pro.cab.saved'))();
onClose();
},
onError: act.onError,
}),
);
const reset = useMutation(trpc.pro.dispatcher.resetStaffInvite.mutationOptions({ onSuccess: (r) => setLink(r.inviteLink), onError: act.onError }));
const created = !staff && !!link;
return (
<Sheet open onClose={onClose} label={staff ? staff.name : t('pro.cab.addStaff')}>
<div className="bc-stack">
<SheetHead title={staff ? staff.name : t('pro.cab.addStaff')} onClose={onClose} />
{created ? (
<>
<p style={{ margin: 0 }}>{t('pro.cab.inviteText')}</p>
<CopyBox url={link!} />
<Button block onClick={onClose}>
{t('pro.cab.done')}
</Button>
</>
) : (
<>
<Field label={t('pro.cab.f.name')}>
<input className="bc-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
</Field>
<Field label={t('pro.cab.f.phone')}>
<input className="bc-input" type="tel" value={f.phone} maxLength={30} onChange={(e) => setF({ ...f, phone: e.target.value })} />
</Field>
<Field label={t('pro.cab.f.languages')}>
<div className="bc-chips">
{LOCALES.map((l) => (
<Chip key={l} pressed={f.languages.includes(l)} onClick={() => setF({ ...f, languages: toggle(f.languages, l) })}>
{l.toUpperCase()}
</Chip>
))}
</div>
</Field>
<Field label={t('pro.cab.f.districts')}>
<div className="bc-chips">
{districts.map((d) => (
<Chip key={d.id} pressed={f.districtIds.includes(d.id)} onClick={() => setF({ ...f, districtIds: toggle(f.districtIds, d.id) })}>
{t(`districts.${d.code}`)}
</Chip>
))}
</div>
</Field>
<Field label={t('pro.cab.f.days')}>
<div className="bc-chips">
{DAYS.map((d) => (
<Chip key={d} pressed={f.days.includes(d)} onClick={() => setF({ ...f, days: toggle(f.days, d) })}>
{t(`pro.cab.wd.${d}`)}
</Chip>
))}
</div>
</Field>
<div className="cab-row2">
<Field label={t('pro.cab.f.from')}>
<input className="bc-input" type="time" step={1800} value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
</Field>
<Field label={t('pro.cab.f.to')}>
<input className="bc-input" type="time" step={1800} value={f.to} onChange={(e) => setF({ ...f, to: e.target.value })} />
</Field>
</div>
<div className="cab-row2">
<Field label={t('pro.cab.f.maxOrders')}>
<input className="bc-input" inputMode="numeric" placeholder="∞" value={f.maxOrders} onChange={(e) => setF({ ...f, maxOrders: e.target.value.replace(/\D/g, '').slice(0, 2) })} />
</Field>
<Field label={t('pro.cab.f.maxHours')}>
<input className="bc-input" inputMode="numeric" placeholder="∞" value={f.maxHours} onChange={(e) => setF({ ...f, maxHours: e.target.value.replace(/\D/g, '').slice(0, 2) })} />
</Field>
</div>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('pro.cab.f.loadHint')}
</p>
{staff && link && (
<>
<p className="bc-small" style={{ margin: 0 }}>
{t('pro.cab.inviteText')}
</p>
<CopyBox url={link} />
</>
)}
<Button
block
disabled={!f.name.trim() || !f.languages.length || toMin(f.to) <= toMin(f.from) || add.isPending || save.isPending}
onClick={() => (staff ? save.mutate({ companyId, providerId: staff.id, staff: payload() }) : add.mutate({ companyId, staff: payload() }))}
sorted.map((p) => (
<button
key={p.id}
type="button"
className="job"
style={{ border: 'none', textAlign: 'left', cursor: p.free ? 'pointer' : 'not-allowed', opacity: p.free ? 1 : 0.5 }}
disabled={!p.free || assign.isPending}
onClick={() => assign.mutate({ companyId, orderId, providerId: p.id })}
>
{staff ? t('pro.cab.save') : t('pro.cab.addStaff')}
</Button>
{staff && (
<div className="bc-row" style={{ gap: 8, flexWrap: 'wrap' }}>
{staff.linked && (
<Button size="sm" variant="ghost" disabled={reset.isPending} onClick={async () => (await confirmDialog(t('pro.cab.resetConfirm'))) && reset.mutate({ companyId, providerId: staff.id })}>
{t('pro.cab.resetInvite')}
</Button>
)}
<Button
size="sm"
variant={staff.active ? 'danger' : 'outline'}
disabled={save.isPending}
onClick={async () => {
if (!staff.active || (await confirmDialog(t('pro.cab.offConfirm', { name: staff.name })))) save.mutate({ companyId, providerId: staff.id, staff: payload(), active: !staff.active });
}}
>
{staff.active ? t('pro.cab.turnOff') : t('pro.cab.turnOn')}
</Button>
<div className="job__main">
<div style={{ fontWeight: 700 }}>{p.name}</div>
<div className="bc-small bc-muted">
{p.languages.map((l) => l.toUpperCase()).join(' ')} · {t('pro.company.load', { count: p.load })}
</div>
</div>
)}
</>
<span className="bc-row">
{p.speaksClientLanguage && <Badge style={{ background: 'var(--bc-home)' }}>{t('pro.company.speaksLanguage')}</Badge>}
<Badge style={{ background: p.free ? 'var(--bc-blue)' : 'var(--bc-bg)' }}>{t(p.free ? 'pro.company.free' : 'pro.company.busy')}</Badge>
</span>
</button>
))
)}
</div>
</Sheet>
);
}
function CopyBox({ url }: { url: string }) {
const { t } = useI18n();
const toast = useToast();
return (
<div className="cab-copy">
<code>{url}</code>
<Button
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard?.writeText(url);
toast(t('pro.cab.copied'));
}}
>
{t('pro.cab.copy')}
</Button>
</div>
);
}
// ───── доход ─────
function Money({ data }: { data: Cabinet }) {
const { t, amd, dt, locale } = useI18n();
const m = data.money;
const terms = data.company.terms;
const month = (p: string) => new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(`${p}-15T12:00:00+04:00`));
return (
<>
<Card tone="dark" className="bc-stack" style={{ gap: 6 }}>
<span className="bc-small" style={{ opacity: 0.75 }}>
{t('pro.cab.balance')}
</span>
<div className="stat__value" style={{ fontSize: 30 }}>
{amd(m.balance)}
</div>
<span className="bc-small" style={{ opacity: 0.85 }}>
{t('pro.cab.accrued')}: {amd(m.accrued)} · {t('pro.cab.paidOut')}: {amd(m.paid)}
</span>
</Card>
<Card tight className="bc-small">
{terms.deal === 'COMMISSION' ? t('pro.cab.terms.COMMISSION', { pct: terms.commissionPct }) : t('pro.cab.terms.PROFIT_SHARE', { pct: terms.profitSharePct, fot: terms.fotPct, tax: terms.taxPct })}
</Card>
{m.months.length > 0 && (
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.cab.byMonth')}</h2>
<Card tight className="bc-stack" style={{ gap: 6 }}>
{m.months.map((x) => (
<div key={x.period} className="bc-between">
<span style={{ textTransform: 'capitalize' }}>{month(x.period)}</span>
<span className="bc-small bc-muted">{t('pro.cab.ordersN', { n: x.orders })}</span>
<b>{amd(x.amount)}</b>
</div>
))}
</Card>
</section>
)}
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.cab.byOrder')}</h2>
{m.recent.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.cab.noIncome')}</p>}
<div className="cab-grid">
{m.recent.map((e) => (
<Card key={e.orderId} tight className="bc-stack" style={{ gap: 2 }}>
<div className="bc-between">
<b>
#{e.number} · {dt(e.at, { day: 'numeric', month: 'short' })}
</b>
<b>{amd(e.amount)}</b>
</div>
<span className="bc-small bc-muted">
{e.staff ? `${e.staff} · ` : ''}
{t('pro.cab.paidBy', { sum: amd(e.paid) })}
{e.deal === 'PROFIT_SHARE' ? ` · ${t('pro.cab.minus', { fot: amd(e.fot), tax: amd(e.tax), pct: e.pct })}` : ` · ${t('pro.cab.minusCommission', { pct: e.pct })}`}
</span>
</Card>
))}
</div>
</section>
{m.payouts.length > 0 && (
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.cab.payouts')}</h2>
<Card tight className="bc-stack" style={{ gap: 6 }}>
{m.payouts.map((p) => (
<div key={p.id} className="bc-between">
<span>{p.paidAt ? dt(p.paidAt, { day: 'numeric', month: 'long' }) : '—'}</span>
<Badge style={{ background: p.status === 'PAID' ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(`pro.cab.payout.${p.status === 'PAID' ? 'paid' : 'pending'}`)}</Badge>
<b>{amd(p.amount)}</b>
</div>
))}
</Card>
</section>
)}
</>
);
}
// ───── настройки ─────
function Settings({ companyId, data }: { companyId: string; data: Cabinet }) {
const { t } = useI18n();
const trpc = useTRPC();
const act = useAction();
const c = data.company;
const [auto, setAuto] = useState(c.autoAssign);
const [gap, setGap] = useState(String(c.travelGapMinutes));
const save = useMutation(trpc.pro.dispatcher.saveSettings.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.saved')), onError: act.onError }));
const remove = useMutation(trpc.pro.dispatcher.removeManager.mutationOptions({ onSuccess: act.onSuccess(), onError: act.onError }));
const logout = useMutation(
trpc.pro.webLogout.mutationOptions({
onSettled: () => {
forgetWebLogin();
window.location.reload();
},
}),
);
const gapN = Number(gap) || 0;
return (
<>
<Card className="bc-stack">
<label className="cab-switch">
<input type="checkbox" checked={auto} onChange={(e) => setAuto(e.target.checked)} />
<span>
<b>{t('pro.cab.auto')}</b>
<span className="bc-small bc-muted" style={{ display: 'block' }}>
{t('pro.cab.autoHint')}
</span>
</span>
</label>
<Field label={t('pro.cab.gap')} error={gapN < c.minTravelGap ? t('pro.cab.gapMin', { min: c.minTravelGap }) : undefined}>
<input className="bc-input" inputMode="numeric" value={gap} onChange={(e) => setGap(e.target.value.replace(/\D/g, '').slice(0, 3))} />
</Field>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('pro.cab.timeoutHint', { min: c.acceptTimeoutMinutes })}
</p>
<Button disabled={save.isPending || gapN < c.minTravelGap} onClick={() => save.mutate({ companyId, autoAssign: auto, travelGapMinutes: gapN })}>
{t('pro.cab.save')}
</Button>
</Card>
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('pro.cab.managers')}</h2>
<Card tight className="bc-stack" style={{ gap: 6 }}>
{data.managers.map((m) => (
<div key={m.id} className="bc-between" style={{ minHeight: 36 }}>
<span>
{m.name}
{m.me && <span className="bc-muted"> · {t('pro.cab.you')}</span>}
</span>
{!m.me && (
<button type="button" className="cab-link" onClick={async () => (await confirmDialog(t('pro.cab.removeManagerConfirm', { name: m.name }))) && remove.mutate({ companyId, managerId: m.id })}>
{t('pro.cab.remove')}
</button>
)}
</div>
))}
</Card>
<p className="bc-small" style={{ margin: 0 }}>
{t('pro.cab.managerInvite')}
</p>
<CopyBox url={c.managerLink} />
</section>
{isWeb() && (
<Button variant="ghost" onClick={() => logout.mutate()}>
{t('pro.cab.logout')}
</Button>
)}
</>
);
}
+3 -5
View File
@@ -64,11 +64,9 @@ export function History() {
<div className="bc-small bc-muted">
{history.data.rate.kind === 'preset'
? t('pro.history.ratePreset', { name: history.data.rate.name })
: history.data.rate.value == null
? t('pro.history.ratePartner')
: history.data.rate.type === 'PERCENT'
? t('pro.history.ratePercent', { value: history.data.rate.value })
: t('pro.history.rateFixed', { value: amd(history.data.rate.value) })}
: history.data.rate.type === 'PERCENT'
? t('pro.history.ratePercent', { value: history.data.rate.value })
: t('pro.history.rateFixed', { value: amd(history.data.rate.value) })}
</div>
{history.data.orders.length === 0 ? (
<p className="bc-muted">{t('pro.history.empty')}</p>
+2 -2
View File
@@ -81,7 +81,7 @@ export function Job({ id }: { id: string }) {
</dd>
<dt>{t('pro.job.price')}</dt>
<dd>
{amd(j.dueAmount)} · {t(`payment.${j.paymentStatus}`)}
{amd(j.totalPrice)} · {t(`payment.${j.paymentStatus}`)}
</dd>
</dl>
{paramsExtras(t, j.params).length > 0 && (
@@ -141,7 +141,7 @@ export function Job({ id }: { id: string }) {
</p>
)}
{j.paymentStatus === 'UNPAID' && j.status !== 'NEW' && (
<Button variant="outline" block disabled={paid.isPending} onClick={async () => (await confirmDialog(t('pro.job.confirmPaid', { amount: amd(j.dueAmount) }))) && paid.mutate({ orderId: j.id })}>
<Button variant="outline" block disabled={paid.isPending} onClick={async () => (await confirmDialog(t('pro.job.confirmPaid', { amount: amd(j.totalPrice) }))) && paid.mutate({ orderId: j.id })}>
{t('pro.job.markPaid')}
</Button>
)}
+1 -3
View File
@@ -2,14 +2,12 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
// API_PROXY — другой локальный API (например, второй экземпляр на соседнем порту)
const api = process.env.API_PROXY ?? 'http://localhost:4000';
export default defineConfig({
plugins: [react()],
server: {
host: true,
allowedHosts: true,
proxy: { '/trpc': api, '/files': api, '/client-log': api },
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
},
build: { target: 'es2022', sourcemap: true },
});
+18 -343
View File
@@ -2,11 +2,9 @@
* Воркер: превращает доменные события в сообщения Telegram на языке получателя (MSG-2, MSG-3)
* и раз в минуту запускает фоновые задачи — напоминания и истечение предложений.
*/
import { getFlow, toYerevan, type ServiceFlow } from '@barev/core';
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { autoAssign, retryCompanyAutoAssign, activatePendingPoints, burnPoints, buildPointSuggestions, expirePointRequests, reconcilePoints, warnExpiringPoints, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { getFile } from '@barev/services/storage';
import { dueReminders, EVENTS_QUEUE, expireOffers, type Ctx, type DomainEvent } from '@barev/services';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
import { compactRows, escapeHtml, TelegramSender, webAppButton, type InlineButton } from '@barev/services/telegram';
@@ -18,7 +16,6 @@ const db = getDb();
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
const notifier = createQueueNotifier(REDIS_URL);
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
startTextOverrides(db);
const clientBot = new TelegramSender(process.env.CLIENT_BOT_TOKEN ?? '', 'client-bot');
const proBot = new TelegramSender(process.env.PRO_BOT_TOKEN ?? '', 'pro-bot');
const CLIENT_APP = process.env.CLIENT_MINIAPP_URL ?? '';
@@ -33,81 +30,6 @@ async function toAdmins(text: string, buttons: InlineButton[][] = []) {
const adminT = createT('ru');
/** Ссылка на раздел админки — Telegram принимает url-кнопки только с https */
const adminButton = (path: string): InlineButton | null => (ADMIN_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${ADMIN_URL}/#${path}` } : null);
/** Карточка заказа: в новой админке, если она уже на сервере, иначе — канбан старой */
const orderButton = (orderId: string): InlineButton | null =>
BACKOFFICE_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/orders/${orderId}` } : adminButton('/');
/** Автораспределение не нашло исполнителя (впервые для заказа) — команде в рабочий бот */
async function reportAuto(orderId: string, res: AutoAssignResult) {
if ((res.result !== 'none' && res.result !== 'gaveUp') || !res.first) return;
const o = await loadOrder(orderId);
if (!o) return;
await toAdmins(
`⚠️ ${escapeHtml(adminT('bot.admin.autoFailed', { number: o.number }))}\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}`,
compactRows([[orderButton(o.id)]]),
);
}
// ───── рассылки клиентам ─────
const running = new Set<string>();
const pause = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Рассылка: порциями по 25, ~25 сообщений в секунду (лимит Telegram — 30).
* Каждый получатель отмечается отдельно, поэтому после перезапуска воркера отправка продолжается с места остановки.
*/
async function runBroadcast(id: string) {
if (running.has(id)) return;
running.add(id);
try {
for (;;) {
const b = await db.broadcast.findUnique({ where: { id } });
if (!b || b.status !== 'SENDING') return;
const batch = await db.broadcastRecipient.findMany({ where: { broadcastId: id, status: 'PENDING' }, take: 25 });
if (!batch.length) {
await db.broadcast.update({ where: { id }, data: { status: 'DONE', finishedAt: new Date() } });
return;
}
const clients = await db.client.findMany({ where: { id: { in: batch.map((r) => r.clientId) } }, include: { user: true } });
const texts = b.texts as Record<string, string>;
for (const r of batch) {
const c = clients.find((x) => x.id === r.clientId);
let status: 'SENT' | 'BLOCKED' | 'FAILED' = 'FAILED';
let error: string | null = null;
if (c) {
const locale = c.user.locale as Locale;
const text = (texts[locale] || texts.ru || '').trim();
try {
const ok = await clientBot.send({
chatId: c.user.telegramId,
text: escapeHtml(text),
buttons: b.appButton ? compactRows([[webAppButton(createT(locale)('bot.client.openApp'), `${CLIENT_APP}/`)]]) : [],
});
status = ok ? 'SENT' : 'BLOCKED';
} catch (e) {
error = String(e).slice(0, 300);
}
}
await db.broadcastRecipient.update({ where: { id: r.id }, data: { status, error, sentAt: new Date() } });
await db.broadcast.update({ where: { id }, data: status === 'SENT' ? { sent: { increment: 1 } } : status === 'BLOCKED' ? { blocked: { increment: 1 } } : { failed: { increment: 1 } } });
await pause(40);
}
}
} catch (e) {
console.error('[worker] broadcast failed', id, e);
} finally {
running.delete(id);
}
}
/** Новая админка, если уже на сервере; иначе — раздел переписки в старой */
// новая админка живёт на admin.* (старая — admin.*/old/), отдельный адрес не обязателен
const BACKOFFICE_URL = process.env.BACKOFFICE_URL || ADMIN_URL;
const chatButton = (threadId: string | null, peer: string): InlineButton | null =>
BACKOFFICE_URL.startsWith('https://') && threadId
? { text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/chats/${threadId}` }
: adminButton('/chats');
const who = (u: { firstName: string | null; lastName?: string | null; username: string | null }) =>
`${escapeHtml(`${u.firstName ?? ''} ${u.lastName ?? ''}`.trim())}${u.username ? ` (@${escapeHtml(u.username)})` : ''}`;
@@ -119,7 +41,6 @@ async function loadOrder(id: string) {
district: true,
client: { include: { user: true } },
provider: { include: { user: true } },
company: { select: { autoAssign: true } },
},
});
}
@@ -167,108 +88,7 @@ async function dispatchers(companyId: string) {
return db.companyDispatcher.findMany({ where: { companyId }, include: { user: true } });
}
// ───── барики ─────
const pts = (locale: Locale, n: number) => formatAmd(locale, n).replace(/\s?֏$/, '');
const pickName = (v: unknown, locale: Locale) => {
const m = (v ?? {}) as Record<string, string>;
return m[locale] || m.ru || m.en || '';
};
const pointsButton = (locale: Locale) => compactRows([[webAppButton(createT(locale)('notify.points.open'), `${CLIENT_APP}/?r=/points`)]]);
async function toClientUser(clientId: string, build: (t: ReturnType<typeof createT>, locale: Locale, c: { pointsActive: number }) => string | null) {
const c = await db.client.findUnique({ where: { id: clientId }, include: { user: true } });
if (!c) return;
const locale = c.user.locale as Locale;
const text = build(createT(locale), locale, c);
if (text) await clientBot.send({ chatId: c.user.telegramId, text, buttons: pointsButton(locale) });
}
const dateL = (locale: Locale, d: Date) => formatDateTime(locale, d, { day: 'numeric', month: 'long' });
async function handlePoints(event: DomainEvent): Promise<boolean> {
switch (event.type) {
case 'points.accrued':
case 'points.activated': {
const a = await db.pointsAccrual.findUnique({ where: { id: event.accrualId }, include: { category: true, bonusType: true } });
if (!a) return true;
await toClientUser(a.clientId, (t, l, c) => {
const comment = a.commentForClient && a.bonusType?.code !== 'housewarming' ? `\n💬 ${escapeHtml(a.commentForClient)}` : '';
if (event.type === 'points.activated') return t('notify.points.activated', { amount: pts(l, a.amount), balance: pts(l, c.pointsActive) });
if (a.kind === 'BONUS') {
const what = a.bonusType?.code === 'housewarming' && a.commentForClient ? t('notify.points.housewarming', { agency: a.commentForClient }) : pickName(a.bonusType?.name, l);
return (a.expiresAt ? t('notify.points.bonus', { amount: pts(l, a.amount), what, until: dateL(l, a.expiresAt) }) : t('notify.points.bonusNoTtl', { amount: pts(l, a.amount), what })) + comment;
}
const what = pickName(a.category?.name, l);
return (a.status === 'PENDING'
? t('notify.points.pending', { amount: pts(l, a.amount), what, date: dateL(l, a.serviceDate), available: dateL(l, a.availableAt) })
: t('notify.points.active', { amount: pts(l, a.amount), what, balance: pts(l, c.pointsActive) })) + comment;
});
return true;
}
case 'points.redeemed': {
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId } });
if (!r) return true;
await toClientUser(r.clientId, (t, l, c) =>
r.orderAmount != null
? t('notify.points.redeemed', { amount: pts(l, r.amount), due: formatAmd(l, Math.max(0, r.orderAmount - r.amount)), balance: pts(l, c.pointsActive) })
: t('notify.points.redeemedNoOrder', { amount: pts(l, r.amount), balance: pts(l, c.pointsActive) }),
);
return true;
}
case 'points.rejected': {
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId } });
if (!r) return true;
await toClientUser(r.clientId, (t) => (r.reason === 'timeout' ? t('notify.points.requestExpired') : t('notify.points.rejected', { reason: escapeHtml(r.reason === 'declined' ? t('notify.points.declinedGeneric') : (r.reason ?? '')) })));
return true;
}
case 'points.reversed': {
const rv = await db.pointsReversal.findUnique({ where: { id: event.reversalId } });
if (!rv) return true;
const reasonText = (t: ReturnType<typeof createT>) => escapeHtml(rv.reason === 'order_cancelled' ? t('notify.points.orderCancelled') : rv.reason === 'order_edited' ? t('notify.points.orderEdited') : rv.reason);
if (rv.targetType === 'REDEMPTION') {
await toClientUser(rv.clientId, (t, l, c) => t('notify.points.returned', { amount: pts(l, rv.amount), balance: pts(l, c.pointsActive) }));
} else {
const a = await db.pointsAccrual.findUnique({ where: { id: rv.targetId }, include: { bonusType: true } });
await toClientUser(rv.clientId, (t, l, c) => t('notify.points.reversed', { what: a?.orderRef ?? pickName(a?.bonusType?.name, l), reason: reasonText(t), balance: pts(l, c.pointsActive) }));
}
return true;
}
case 'points.expiring':
await toClientUser(event.clientId, (t, l) => t('notify.points.expiring', { days: event.days, amount: pts(l, event.amount) }));
return true;
case 'points.expired':
await toClientUser(event.clientId, (t, l) => t('notify.points.expired', { amount: pts(l, event.amount) }));
return true;
case 'points.requested': {
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId }, include: { client: { include: { user: true } }, order: true } });
if (!r) return true;
const order = r.order ? `\n${adminT('bot.admin.points.forOrder', { number: r.order.number, sum: formatAmd('ru', r.order.totalPrice) })}` : r.note ? `\n💬 ${escapeHtml(r.note)}` : '';
const buttons: InlineButton[][] = [[...(r.order ? [{ text: adminT('bot.admin.points.confirm'), callback_data: `pr:ok:${r.id}` }] : []), { text: adminT('bot.admin.points.reject'), callback_data: `pr:no:${r.id}` }]];
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=requests` }]] : [];
await toAdmins(`🪙 <b>${adminT('bot.admin.points.request', { amount: pts('ru', r.amount) })}</b>\n${who(r.client.user)} · ${adminT('bot.admin.points.balance', { balance: pts('ru', r.client.pointsActive) })}${order}`, compactRows([...buttons, ...link]));
return true;
}
case 'points.suggestions': {
const open = await db.pointsSuggestion.count({ where: { status: 'OPEN' } });
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=suggestions` }]] : [];
await toAdmins(`💡 ${adminT('bot.admin.points.suggestions', { count: event.count, open })}`, compactRows(link));
return true;
}
case 'points.overLimit': {
const s = await db.pointsSuggestion.findUnique({ where: { id: event.suggestionId }, include: { client: { include: { user: true } } } });
if (!s) return true;
const by = typeof (s.payload as Record<string, unknown>).adminId === 'string' ? await db.adminUser.findUnique({ where: { id: (s.payload as Record<string, string>).adminId } }) : null;
const owners = await db.adminUser.findMany({ where: { active: true, telegramId: { not: null }, roles: { has: 'SUPERADMIN' } } });
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=suggestions` }]] : [];
for (const o of owners) await proBot.send({ chatId: o.telegramId!, text: `⚠️ ${adminT('bot.admin.points.overLimit', { by: escapeHtml(by?.name ?? '—'), amount: pts('ru', s.suggestedAmount) })}\n${who(s.client.user)}`, buttons: compactRows(link) });
return true;
}
default:
return false;
}
}
async function handle(event: DomainEvent) {
if (event.type.startsWith('points.') && (await handlePoints(event))) return;
switch (event.type) {
case 'order.created': {
const o = await loadOrder(event.orderId);
@@ -277,10 +97,8 @@ async function handle(event: DomainEvent) {
await toClient(o, 'notify.created', { total: formatAmd(locale, o.totalPrice) });
await toAdmins(
`📦 <b>${adminT('bot.admin.newOrder', { number: o.number })}</b>\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}\n${who(o.client.user)} · ${formatAmd('ru', o.totalPrice)}`,
compactRows([[orderButton(o.id)]]),
compactRows([[adminButton('/')]]),
);
// автораспределение: сразу предлагаем лучшему свободному исполнителю
await reportAuto(o.id, await autoAssign(ctx, o.id));
return;
}
case 'order.status': {
@@ -288,15 +106,6 @@ async function handle(event: DomainEvent) {
if (!o) return;
// клиент — о каждом переходе (STS)
await toClient(o, `notify.status.${o.service.flow}.${event.to}`);
// заказ выполнен — просим оценить (если ещё не оценил в приложении)
if (getFlow(o.service.flow as ServiceFlow).done.includes(event.to) && !(await db.rating.findUnique({ where: { orderId: o.id } }))) {
const t = createT(o.client.user.locale as Locale);
await clientBot.send({
chatId: o.client.user.telegramId,
text: t('notify.rateAsk'),
buttons: [[1, 2, 3, 4, 5].map((n) => ({ text: `${n} ★`, callback_data: `rate:${o.id}:${n}` }))],
});
}
if (event.to === 'CANCELLED' && o.provider?.user) await toProvider(o, o.provider.user, 'notify.pro.cancelled');
if (event.to === 'ASSIGNED' && event.from === 'NEW' && o.provider?.user && o.companyId) {
// назначил диспетчер компании — сотрудник узнаёт сразу
@@ -309,14 +118,6 @@ async function handle(event: DomainEvent) {
if (o) await toClient(o, `notify.event.${event.code}`);
return;
}
case 'order.edited': {
const o = await loadOrder(event.orderId);
if (!o) return;
const locale = o.client.user.locale as Locale;
await toClient(o, 'notify.orderEdited', { total: formatAmd(locale, o.totalPrice - o.pointsUsed) });
if (o.provider?.user) await toProvider(o, o.provider.user, 'notify.pro.orderEdited');
return;
}
case 'order.rescheduled': {
const o = await loadOrder(event.orderId);
if (!o) return;
@@ -365,34 +166,10 @@ async function handle(event: DomainEvent) {
const offer = await db.orderOffer.findUnique({ where: { id: event.offerId }, include: { provider: true } });
const companyId = offer?.provider?.companyId;
const o = offer && (await loadOrder(offer.orderId));
if (!o) return;
// частный исполнитель отказался или промолчал — система предлагает следующему
if (!companyId) {
await reportAuto(o.id, await autoAssign(ctx, o.id));
return;
}
// при автораспределении партнёра заказ сам уйдёт следующему; менеджеру пишем, если сотрудников не осталось (company.noStaff)
if (o.company?.autoAssign) return;
if (!o || !companyId) return;
for (const d of await dispatchers(companyId)) await toProvider(o, d.user, 'notify.dispatcher.returned');
return;
}
case 'company.noStaff': {
const o = await loadOrder(event.orderId);
if (!o) return;
for (const d of await dispatchers(event.companyId)) await toProvider(o, d.user, 'notify.dispatcher.noStaff', proDetails(o, d.user.locale as Locale));
return;
}
case 'partner.returned': {
const o = await loadOrder(event.orderId);
const company = await db.providerCompany.findUnique({ where: { id: event.companyId } });
if (!o || !company) return;
await toAdmins(
`↩️ ${escapeHtml(adminT(event.reason === 'declined' ? 'bot.admin.partnerDeclined' : 'bot.admin.partnerExpired', { number: o.number, partner: company.name }))}\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}`,
compactRows([[orderButton(o.id)]]),
);
if (event.reason === 'expired') for (const d of await dispatchers(event.companyId)) await toProvider(o, d.user, 'notify.dispatcher.expired');
return;
}
case 'company.order': {
const o = await loadOrder(event.orderId);
if (!o) return;
@@ -427,89 +204,23 @@ async function handle(event: DomainEvent) {
return;
}
case 'concierge.reply': {
const msg = await db.conciergeMessage.findUnique({
where: { id: event.messageId },
include: { client: { include: { user: true } }, provider: { include: { user: true } }, thread: { select: { channel: true } } },
});
if (!msg || msg.internal) return;
const to = msg.client ? { bot: clientBot, user: msg.client.user } : msg.provider?.user ? { bot: proBot, user: msg.provider.user } : null;
if (!to) return;
const t = createT(to.user.locale as Locale);
// клиенту — от кого ответ: консьерж или поддержка; исполнителю — оператор
const head = msg.client ? t(msg.thread?.channel === 'CONCIERGE' ? 'notify.concierge' : 'notify.support') : t('notify.operator');
const text = `<b>${head}</b>${msg.text ? `\n${escapeHtml(msg.text)}` : ''}`;
let delivered: boolean;
if (msg.fileKey) {
const body = await getFile(msg.fileKey);
delivered = await to.bot.sendFile({ chatId: to.user.telegramId, caption: text, body, fileName: msg.fileName ?? 'file', mime: msg.mime ?? 'application/octet-stream' });
} else {
delivered = await to.bot.send({ chatId: to.user.telegramId, text });
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } } } });
if (!msg) return;
if (msg.client) {
const t = createT(msg.client.user.locale as Locale);
await clientBot.send({ chatId: msg.client.user.telegramId, text: `<b>${t('notify.concierge')}</b>\n${escapeHtml(msg.text)}` });
} else if (msg.provider?.user) {
const t = createT(msg.provider.user.locale as Locale);
await proBot.send({ chatId: msg.provider.user.telegramId, text: `<b>${t('notify.operator')}</b>\n${escapeHtml(msg.text)}` });
}
if (!delivered) await db.conciergeMessage.update({ where: { id: msg.id }, data: { deliveryError: 'UNREACHABLE' } });
return;
}
case 'concierge.message': {
const msg = await db.conciergeMessage.findUnique({
where: { id: event.messageId },
include: { client: { include: { user: true } }, provider: { include: { user: true } }, thread: true, order: { include: { service: true, district: true } } },
});
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } } } });
if (!msg) return;
let from = msg.client ? who(msg.client.user) : `${escapeHtml(msg.provider?.name ?? '')} · ${adminT('bot.admin.provider')}`;
// исполнитель: по какому заказу пишет, или что сейчас без заказа
if (msg.providerId) {
const o = msg.order;
const orderLine = o
? [
adminT('bot.admin.order', { number: o.number }),
adminT(`services.${o.service.code}.name`),
formatDateTime('ru', o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
o.district ? adminT(`districts.${o.district.code}`) : null,
]
.filter(Boolean)
.join(' · ')
: adminT('bot.admin.noOrder');
from += `\n📦 ${escapeHtml(orderLine)}`;
}
const from = msg.client ? who(msg.client.user) : `${escapeHtml(msg.provider?.name ?? '')} · ${adminT('bot.admin.provider')}`;
const peer = msg.clientId ? `c:${msg.clientId}` : `p:${msg.providerId}`;
const channel = msg.thread ? `${adminT(msg.thread.channel === 'CONCIERGE' ? 'bot.admin.channelConcierge' : 'bot.admin.channelSupport')} · ` : '';
const body = msg.text ? escapeHtml(msg.text) : '';
const file = msg.tgFileId || msg.fileKey ? `📎 ${escapeHtml(msg.fileName ?? adminT('bot.admin.file'))}` : '';
const text = `💬 ${channel}<b>${from}</b>\n${[file, body].filter(Boolean).join('\n')}`;
const buttons = compactRows([[{ text: adminT('bot.admin.reply'), callback_data: msg.threadId ? `ar:t:${msg.threadId}` : `ar:${peer}` }, chatButton(msg.threadId, peer)]]);
const recipients = msg.thread ? await chatRecipients(ctx, msg.thread) : await db.adminUser.findMany({ where: { active: true, tgNotify: true, telegramId: { not: null } } });
for (const a of recipients) await proBot.send({ chatId: a.telegramId!, text, buttons });
return;
}
case 'broadcast.start':
// отправка идёт вне задачи очереди — длинная рассылка не держит очередь
void runBroadcast(event.broadcastId);
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 } }),
db.adminUser.findUnique({ where: { id: event.assigneeId } }),
db.adminUser.findUnique({ where: { id: event.byAdminId }, select: { name: true } }),
]);
if (!thread || !assignee?.telegramId || !assignee.tgNotify) return;
const name = thread.client ? who(thread.client.user) : escapeHtml(thread.provider?.name ?? '');
const peer = thread.clientId ? `c:${thread.clientId}` : `p:${thread.providerId}`;
await proBot.send({
chatId: assignee.telegramId,
text: `👋 ${adminT('bot.admin.chatAssigned', { by: escapeHtml(by?.name ?? ''), name })}`,
buttons: compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:t:${thread.id}` }, chatButton(thread.id, peer)]]),
});
await toAdmins(`💬 <b>${from}</b>\n${escapeHtml(msg.text)}`, compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:${peer}` }, adminButton(`/concierge?${msg.clientId ? 'client' : 'provider'}=${msg.clientId ?? msg.providerId}`)]]));
return;
}
case 'lead.submitted': {
@@ -525,7 +236,7 @@ async function handle(event: DomainEvent) {
const pkg = lead.package ? adminT(`landing.quiz.package_${lead.package}`) : '—';
await toAdmins(
`🧳 <b>${adminT('bot.admin.newLead', { number: lead.number })}</b>\n${escapeHtml(lead.name)} · ${escapeHtml(lead.contact)}\n${when} · ${pkg}${lead.adminNote ? `\n${escapeHtml(lead.adminNote)}` : ''}`,
compactRows([[adminButton('/moves')]]),
compactRows([[adminButton('/leads')]]),
);
return;
}
@@ -535,17 +246,16 @@ async function handle(event: DomainEvent) {
const what = r.kind === 'SUBSCRIPTION' ? adminT('admin.requests.subscription', { plan: r.plan === 'ELITE' ? 'Barev Elite' : 'Barev+' }) : adminT(`services.${r.serviceCode}.name`);
await toAdmins(
`⭐ <b>${adminT('bot.admin.newRequest', { number: r.number })}</b>\n${who(r.client.user)}\n${escapeHtml(what)}${r.comment ? `\n${escapeHtml(r.comment)}` : ''}`,
compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:c:${r.clientId}` }, adminButton('/requests')]]),
compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:c:${r.clientId}` }, adminButton('/app-requests')]]),
);
return;
}
case 'webRequest.created': {
const r = await db.webRequest.findUnique({ where: { id: event.requestId }, include: { service: true, district: true } });
if (!r) return;
const date = r.preferredAt ? formatDateTime('ru', r.preferredAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }) : adminT('bot.admin.noTime');
const place = r.district ? adminT(`districts.${r.district.code}`) : adminT('bot.admin.noAddress');
const date = formatDateTime('ru', r.preferredAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
await toAdmins(
`🧹 <b>${adminT('bot.admin.newWebRequest', { number: r.number })}</b>\n${escapeHtml(adminT(`services.${r.service.code}.name`))} · ${date}\n${escapeHtml(r.name ?? '')} ${escapeHtml(r.phone)} · ${escapeHtml(place)} · ${formatAmd('ru', r.totalPrice)}`,
`🧹 <b>${adminT('bot.admin.newWebRequest', { number: r.number })}</b>\n${escapeHtml(adminT(`services.${r.service.code}.name`))} · ${date}\n${escapeHtml(r.name ?? '')} ${escapeHtml(r.phone)} · ${escapeHtml(adminT(`districts.${r.district.code}`))} · ${formatAmd('ru', r.totalPrice)}`,
compactRows([[adminButton('/requests')]]),
);
return;
@@ -576,47 +286,12 @@ async function tick() {
try {
const [reminders, expired] = await Promise.all([dueReminders(ctx), expireOffers(ctx)]);
if (reminders || expired) console.log(`[worker] reminders=${reminders} expiredOffers=${expired}`);
// заказы, которые ещё ждут исполнителя, — пробуем распределить снова
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
// заказы партнёров с автораспределением, которые ждут сотрудника
await retryCompanyAutoAssign(ctx);
await pointsJobs();
// задачи продажников, у которых наступил срок
await dueSalesTasks(ctx);
// рассылки, прерванные перезапуском, — продолжить
for (const b of await db.broadcast.findMany({ where: { status: 'SENDING' }, select: { id: true } })) void runBroadcast(b.id);
} catch (e) {
console.error('[worker] tick failed', e);
} finally {
ticking = false;
}
}
/**
* Барики: ожидающие → доступные и просроченные запросы — каждую минуту;
* раз в час — предложения системы; раз в сутки — сгорание (03:00), сверка (04:00), предупреждения (11:00).
* Задачи идемпотентны: повторный запуск после рестарта ничего не задваивает.
*/
const lastRun = new Map<string, string>();
async function once(key: string, period: string, fn: () => Promise<unknown>) {
if (lastRun.get(key) === period) return;
lastRun.set(key, period);
await fn();
}
async function pointsJobs() {
await activatePendingPoints(ctx);
await expirePointRequests(ctx);
const y = toYerevan(new Date());
const day = `${y.year}-${y.month}-${y.day}`;
await once('suggestions', `${day} ${y.hour}`, () => buildPointSuggestions(ctx));
if (y.hour >= 3) await once('burn', day, () => burnPoints(ctx));
if (y.hour >= 4)
await once('reconcile', day, async () => {
const diff = await reconcilePoints(db);
if (diff.length) await toAdmins(`⚠️ ${adminT('bot.admin.points.mismatch', { count: diff.length })}`);
});
if (y.hour >= 11) await once('warn', day, () => warnExpiringPoints(ctx));
}
setInterval(tick, 60_000);
void tick();
+2 -5
View File
@@ -1,7 +1,6 @@
# Barev Club — образы для продакшена.
# app — Node: API + воркер + боты (один процесс) и лендинг Next.js (тот же образ)
# web — nginx: статика Mini App и админки + прокси /trpc и /files на API
# admin.* — новая админка (apps/backoffice), admin.*/old/ — старая (apps/admin)
FROM node:22-alpine AS deps
RUN corepack enable
WORKDIR /repo
@@ -16,15 +15,13 @@ ARG CLIENT_BOT_USERNAME=barevclub_bot
ENV VITE_LANDING_URL=$LANDING_URL NEXT_PUBLIC_API_URL=$API_PUBLIC_URL CLIENT_BOT_USERNAME=$CLIENT_BOT_USERNAME NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @barev/miniapp-client build \
&& pnpm --filter @barev/miniapp-pro build \
&& ADMIN_BASE=/old/ pnpm --filter @barev/admin build \
&& VITE_OLD_ADMIN_URL=/old/ pnpm --filter @barev/backoffice build \
&& pnpm --filter @barev/admin build \
&& pnpm --filter @barev/landing build
FROM nginx:alpine AS web
COPY --from=build /repo/apps/miniapp-client/dist /srv/client
COPY --from=build /repo/apps/miniapp-pro/dist /srv/pro
COPY --from=build /repo/apps/backoffice/dist /srv/admin
COPY --from=build /repo/apps/admin/dist /srv/admin/old
COPY --from=build /repo/apps/admin/dist /srv/admin
COPY infra/prod/nginx.conf /etc/nginx/conf.d/default.conf
FROM node:22-alpine AS app
-2
View File
@@ -16,8 +16,6 @@ LANDING_URL=https://barevclub.com
CLIENT_MINIAPP_URL=https://app.barevclub.com
PRO_MINIAPP_URL=https://pro.barevclub.com
ADMIN_URL=https://admin.barevclub.com
# новая админка — на ADMIN_URL (старая — ADMIN_URL/old/); BACKOFFICE_URL нужен, только если она на другом адресе
# BACKOFFICE_URL=
# серверный адрес API для лендинга (внутри docker-сети)
API_URL=http://barev-app:4000
API_PORT=4000
-10
View File
@@ -75,16 +75,6 @@ server {
add_header Cache-Control "no-cache";
}
# старая админка: admin.barevclub.com/old/ — своя index.html, тоже без кеша
location ^~ /old/ {
root /srv/$barev_site;
try_files $uri $uri/ /old/index.html;
location ~ \.html$ {
root /srv/$barev_site;
add_header Cache-Control "no-cache";
}
}
# статика с хешами в имени — кешируем надолго
location /assets/ {
root /srv/$barev_site;
+5 -11
View File
@@ -230,21 +230,15 @@ describe('Barev+ и агентства (ONB-4, SUB-3, ADM-8)', () => {
const now = new Date('2026-10-17T10:00:00Z');
it('подарочный месяц — один раз', () => {
const ok = decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 1, currentEndsAt: null });
expect(ok).toEqual({ ok: true, startsAt: now, endsAt: new Date('2026-11-17T10:00:00Z') });
expect(decideGift({ now, giftUsedAt: now, agencyActive: true, giftMonths: 1, currentEndsAt: null })).toEqual({ ok: false, reason: 'ALREADY_USED' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: false, giftMonths: 1, currentEndsAt: null })).toEqual({ ok: false, reason: 'AGENCY_INACTIVE' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 0, currentEndsAt: null })).toEqual({ ok: false, reason: 'NO_GIFT' });
});
it('подарок на несколько месяцев', () => {
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 3, currentEndsAt: null });
expect(r.ok && r.endsAt).toEqual(new Date('2027-01-17T10:00:00Z'));
const ok = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: null });
expect(ok).toEqual({ ok: true, startsAt: now, endsAt: new Date('2026-11-16T10:00:00Z') });
expect(decideGift({ now, giftUsedAt: now, agencyActive: true, giftDays: 30, currentEndsAt: null })).toEqual({ ok: false, reason: 'ALREADY_USED' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: false, giftDays: 30, currentEndsAt: null })).toEqual({ ok: false, reason: 'AGENCY_INACTIVE' });
});
it('подарок продлевает действующую подписку', () => {
const endsAt = new Date('2026-10-20T00:00:00Z');
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftMonths: 1, currentEndsAt: endsAt });
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: endsAt });
expect(r.ok && r.startsAt).toEqual(endsAt);
});
-2
View File
@@ -13,5 +13,3 @@ export * from './settings.ts';
export * from './payments.ts';
export * from './catalog.ts';
export * from './payout.ts';
export * from './partners.ts';
export * from './points.ts';
+6 -8
View File
@@ -1,4 +1,4 @@
import { addMonths } from './time.ts';
import { addMonths, DAY } from './time.ts';
export interface SubscriptionLike {
startsAt: Date;
@@ -33,25 +33,23 @@ export function discountFor(level: MembershipLevel, plusPct: number, elitePct =
export type GiftDecision =
| { ok: true; startsAt: Date; endsAt: Date }
| { ok: false; reason: 'ALREADY_USED' | 'AGENCY_INACTIVE' | 'NO_GIFT' };
| { ok: false; reason: 'ALREADY_USED' | 'AGENCY_INACTIVE' };
/**
* SUB-3 / ONB-4: подарок по коду агентства (уровень и срок задаются у агентства), один раз на клиента.
* Если у клиента уже есть активная подписка, подарок начинается с даты её окончания.
* SUB-3 / ONB-4: подарочный месяц по коду агентства, один раз на клиента.
* Если у клиента уже есть активный Barev+, подарок продлевает его с даты окончания.
*/
export function decideGift(input: {
now: Date;
giftUsedAt: Date | null;
agencyActive: boolean;
/** срок подарка агентства в месяцах; 0 — агентство ничего не дарит */
giftMonths: number;
giftDays: number;
currentEndsAt: Date | null;
}): GiftDecision {
if (input.giftUsedAt) return { ok: false, reason: 'ALREADY_USED' };
if (!input.agencyActive) return { ok: false, reason: 'AGENCY_INACTIVE' };
if (input.giftMonths <= 0) return { ok: false, reason: 'NO_GIFT' };
const startsAt = input.currentEndsAt && input.currentEndsAt > input.now ? input.currentEndsAt : input.now;
return { ok: true, startsAt, endsAt: addMonths(startsAt, input.giftMonths) };
return { ok: true, startsAt, endsAt: new Date(startsAt.getTime() + input.giftDays * DAY) };
}
/** Клиент «принадлежит» агентству N месяцев с первого /start (решение владельца) */
-43
View File
@@ -1,43 +0,0 @@
import { describe, expect, it } from 'vitest';
import { partnerSplit, partnerTerms, withinDailyLimits } from './partners.ts';
import { isProviderFreeWithGap } from './slots.ts';
import { fromYerevan } from './time.ts';
describe('партнёры услуг: деньги', () => {
it('как Uber: наш % с оплаченного, остальное партнёру', () => {
const r = partnerSplit(20_000, { deal: 'COMMISSION', commissionPct: 30, profitSharePct: 0, fotPct: 0, taxPct: 0 });
expect(r).toMatchObject({ partner: 14_000, margin: 6_000, fot: 0, tax: 0, pct: 30 });
});
it('расходы наши: % партнёра от (оплачено − ФОТ − налог)', () => {
const r = partnerSplit(20_000, { deal: 'PROFIT_SHARE', commissionPct: 0, profitSharePct: 30, fotPct: 60, taxPct: 5 });
// ФОТ 12 000, налог 1 000, прибыль 7 000 → партнёру 2 100
expect(r).toMatchObject({ fot: 12_000, tax: 1_000, partner: 2_100, cost: 14_100, margin: 5_900 });
});
it('ФОТ и налог по умолчанию из настроек, свои — у партнёра', () => {
const base = { deal: 'PROFIT_SHARE' as const, commissionPct: 0, profitSharePct: 50 };
expect(partnerTerms({ ...base, fotPct: null, taxPct: null }, { fotPct: 50, taxPct: 10 })).toMatchObject({ fotPct: 50, taxPct: 10 });
expect(partnerTerms({ ...base, fotPct: 40, taxPct: 0 }, { fotPct: 50, taxPct: 10 })).toMatchObject({ fotPct: 40, taxPct: 0 });
});
});
describe('партнёры услуг: нагрузка сотрудника', () => {
it('лимит заказов и часов в день', () => {
const day = [{ durationMinutes: 180 }, { durationMinutes: 120 }];
expect(withinDailyLimits({ maxOrdersPerDay: 3, maxHoursPerDay: null }, day, 60)).toBe(true);
expect(withinDailyLimits({ maxOrdersPerDay: 2, maxHoursPerDay: null }, day, 60)).toBe(false);
expect(withinDailyLimits({ maxOrdersPerDay: null, maxHoursPerDay: 6 }, day, 60)).toBe(true);
expect(withinDailyLimits({ maxOrdersPerDay: null, maxHoursPerDay: 6 }, day, 61)).toBe(false);
});
it('между заказами остаётся время на дорогу', () => {
// суббота 24.10.2026, график 9:00–21:00 (0 — понедельник), занят 10:00–12:00
const p = { id: 'p', schedule: [{ weekday: 5, startMinute: 9 * 60, endMinute: 21 * 60 }], busy: [{ start: fromYerevan(2026, 10, 24, 600), end: fromYerevan(2026, 10, 24, 720) }] };
const at = (m: number, len = 120) => ({ start: fromYerevan(2026, 10, 24, m), end: fromYerevan(2026, 10, 24, m + len) });
expect(isProviderFreeWithGap(p, at(12 * 60), 0)).toBe(true);
expect(isProviderFreeWithGap(p, at(12 * 60 + 30), 60)).toBe(false);
expect(isProviderFreeWithGap(p, at(13 * 60), 60)).toBe(true);
expect(isProviderFreeWithGap(p, at(7 * 60 + 30, 90), 60)).toBe(false);
});
});
-70
View File
@@ -1,70 +0,0 @@
/** Условия партнёра услуг (клининговой компании) */
export interface PartnerTerms {
deal: 'PROFIT_SHARE' | 'COMMISSION';
/** COMMISSION: наш % с заказа */
commissionPct: number;
/** PROFIT_SHARE: % партнёра от прибыли */
profitSharePct: number;
/** PROFIT_SHARE: ФОТ клинера и налог, % от оплаченного (уже с учётом настроек по умолчанию) */
fotPct: number;
taxPct: number;
}
export interface PartnerSplit {
/** фактически оплачено клиентом */
paid: number;
/** PROFIT_SHARE: ФОТ клинера (платим мы) и налог */
fot: number;
tax: number;
/** доход партнёра */
partner: number;
/** всё, что мы платим исполнителям по заказу: ФОТ + доля партнёра (без налога) — для сравнения с фрилансерами */
cost: number;
/** нам остаётся до налога: paid − cost */
margin: number;
/** % партнёра (PROFIT_SHARE) или наша комиссия (COMMISSION) — то, что запоминаем в начислении */
pct: number;
}
const pctOf = (sum: number, pct: number) => Math.floor((sum * pct) / 100);
/**
* Деньги по заказу партнёра.
* - «Расходы наши» (PROFIT_SHARE): партнёр получает X% от (оплачено − ФОТ − налог); ФОТ клинеру платим мы.
* - «Как Uber» (COMMISSION): мы забираем свой %, остальное — партнёру; его расходы — его.
*/
export function partnerSplit(paid: number, t: PartnerTerms): PartnerSplit {
if (t.deal === 'COMMISSION') {
const ours = pctOf(paid, t.commissionPct);
const partner = paid - ours;
return { paid, fot: 0, tax: 0, partner, cost: partner, margin: ours, pct: t.commissionPct };
}
const fot = pctOf(paid, t.fotPct);
const tax = pctOf(paid, t.taxPct);
const profit = Math.max(0, paid - fot - tax);
const partner = pctOf(profit, t.profitSharePct);
const cost = fot + partner;
return { paid, fot, tax, partner, cost, margin: paid - cost, pct: t.profitSharePct };
}
/** Условия партнёра с подставленными общими ФОТ и налогом из настроек */
export function partnerTerms(
c: { deal: 'PROFIT_SHARE' | 'COMMISSION'; commissionPct: number; profitSharePct: number; fotPct: number | null; taxPct: number | null },
defaults: { fotPct: number; taxPct: number },
): PartnerTerms {
return { deal: c.deal, commissionPct: c.commissionPct, profitSharePct: c.profitSharePct, fotPct: c.fotPct ?? defaults.fotPct, taxPct: c.taxPct ?? defaults.taxPct };
}
/** Лимиты нагрузки сотрудника на день: заказов и часов (пусто — без ограничения) */
export function withinDailyLimits(
limits: { maxOrdersPerDay: number | null; maxHoursPerDay: number | null },
dayOrders: { durationMinutes: number }[],
newDurationMinutes: number,
): boolean {
if (limits.maxOrdersPerDay != null && dayOrders.length + 1 > limits.maxOrdersPerDay) return false;
if (limits.maxHoursPerDay != null) {
const minutes = dayOrders.reduce((s, o) => s + o.durationMinutes, 0) + newDurationMinutes;
if (minutes > limits.maxHoursPerDay * 60) return false;
}
return true;
}
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more