Bariki cashback, order edit, and current work in progress

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:57:03 +04:00
1 parent dda917fc71
commit 45477fb018
94 files changed
+10955 -650

No files matched your search

+8
View File
@@ -19,6 +19,8 @@ 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';
@@ -57,7 +59,9 @@ const NAV: NavItem[] = [
{ path: '/chats', key: 'nav.chats', icon: 'chats', roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
{ path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT', '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' },
@@ -95,6 +99,7 @@ function Shell({ me }: { me: Me }) {
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');
@@ -112,6 +117,9 @@ function Shell({ me }: { 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} />;
+37
View File
@@ -123,3 +123,40 @@
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;
}
+851 -32
View File
@@ -43,8 +43,8 @@ const ru = {
'nav.chats': 'Чаты',
'nav.orders': 'Заказы',
'nav.providers': 'Исполнители',
'nav.agencies': 'Агентства',
'nav.venues': 'Заведения',
'nav.agencies': 'Риэлторские агентства',
'nav.venues': 'Партнёры клуба',
'nav.crm': 'CRM',
'nav.products': 'Продукты',
'nav.journal': 'Журнал',
@@ -656,7 +656,7 @@ const ru = {
'st.saveN': 'Сохранить ({n})',
'st.group.orders': 'Заказы',
'st.group.subs': 'Подписка',
'st.group.agencies': 'Агентства',
'st.group.agencies': 'Риэлторские агентства',
'st.group.sales': 'Бонус продажников',
'st.group.site': 'Цены на сайте',
'st.group.other': 'Прочее',
@@ -809,22 +809,22 @@ const ru = {
'venue.cat.shop': 'Магазин',
'venue.cat.beauty': 'Красота',
'venue.cat.other': 'Другое',
'ag.title': 'Агентства',
'ag.add': 'Новое агентство',
'ag.editTitle': 'Агентство',
'ag.empty': 'Агентств пока нет.',
'ag.col.name': 'Агентство',
'ag.title': 'Риэлторские агентства',
'ag.add': 'Новое риэлторское агентство',
'ag.editTitle': 'Риэлторское агентство',
'ag.empty': 'Риэлторских агентств пока нет.',
'ag.col.name': 'Риэлторское агентство',
'ag.col.clients': 'Клиенты',
'ag.col.gifts': 'Подарки',
'ag.col.month': 'Вознаграждение, месяц',
'ag.col.balance': 'К выплате',
'ag.hint': 'Вознаграждение — % от оплаченных заказов клиентов агентства, пока они закреплены за ним.',
'ag.inactive': 'выключено',
'ag.back': 'Все агентства',
'ag.back': 'Все риэлторские агентства',
'ag.pct': '{n}% с заказов клиентов',
'ag.byS': 'подключил(а) {name}',
'ag.link': 'Ссылка для клиентов',
'ag.linkHint': 'По ссылке клиент открывает бота, получает подарочный месяц и закрепляется за агентством. Или вводит код {code} командой /code.',
'ag.linkHint': 'По ссылке клиент открывает бота, получает подарок агентства и закрепляется за ним. Или вводит код {code} командой /code.',
'ag.clients': 'Клиенты',
'ag.giftsN': 'подарков: {n}',
'ag.noClients': 'Клиентов пока нет.',
@@ -838,7 +838,7 @@ const ru = {
'ag.sales': 'Подключил продажник',
'ag.contactPh': 'Имя, телефон, Telegram',
'ag.active': 'Агентство работает (код принимается)',
'vn.title': 'Заведения',
'vn.title': 'Партнёры клуба',
'vn.add': 'Новое заведение',
'vn.editTitle': 'Заведение',
'vn.empty': 'Заведений пока нет.',
@@ -1169,6 +1169,279 @@ const ru = {
'audit.bo.chats.openFor': 'открыл(а) чат с клиентом',
'audit.bo.chats.templates.save': 'изменил(а) шаблон ответа',
'audit.bo.chats.templates.remove': 'удалил(а) шаблон ответа',
'nav.servicePartners': 'Партнёры услуг',
'st.group.partners': 'Партнёры услуг',
'ag.giftPlan': 'Подарок новым клиентам',
'ag.giftMonths': 'На сколько',
'ag.giftHint': 'Действует для новых клиентов; у тех, кто уже получил подарок, ничего не меняется.',
'ag.noGift': 'без подарка',
'ag.giftValue': '{plan} на {n} мес.',
'ag.monthsN': '{n} мес.',
'ord.partner': 'партнёр',
'ord.best': 'выгоднее всего',
'ord.partnerFree': 'свободно {n} из {of}',
'ord.candMoney': 'Платим {cost} · нам остаётся {margin}',
'prv.ratePartnerPays': 'Зарплату платит партнёр',
'rq.missing': 'не хватает данных',
'rq.noTime': 'не указано',
'rq.noDistrict': 'район не указан',
'rq.noAddress': 'адрес не указан',
'sp.title': 'Партнёры услуг',
'sp.add': 'Новый партнёр',
'sp.editTitle': 'Партнёр услуг',
'sp.lead': 'Клининговые компании со своими сотрудниками. Менеджер партнёра получает ссылку, сам добавляет сотрудников и распределяет заказы. Заказ партнёру назначает оператор в карточке заказа.',
'sp.empty': 'Партнёров пока нет.',
'sp.col.name': 'Партнёр',
'sp.col.staff': 'Сотрудники',
'sp.col.active': 'Заказы в работе',
'sp.col.month': 'Доход партнёра, месяц',
'sp.col.balance': 'К выплате',
'sp.noManager': 'нет менеджера',
'sp.auto': 'автораспределение',
'sp.back': 'Все партнёры услуг',
'sp.terms.COMMISSION': 'как Uber: наши {pct}% с заказа',
'sp.terms.PROFIT_SHARE': 'расходы наши: ФОТ {fot}%, налог {tax}%, партнёру {pct}% прибыли',
'sp.managerLink': 'Ссылка для менеджера',
'sp.managerLinkHint': 'Отдайте ссылку менеджеру партнёра: он откроет рабочий бот и получит кабинет — заказы, сотрудники, автораспределение, доход. Кабинет открывается и на компьютере (кнопка в боте).',
'sp.resetLink': 'Новая ссылка',
'sp.resetConfirm': 'Сделать новую ссылку? Старая перестанет работать, подключённые менеджеры останутся.',
'sp.linkReset': 'Ссылка обновлена',
'sp.managers': 'Менеджеры',
'sp.noManagers': 'Менеджер ещё не подключился по ссылке.',
'sp.managerSince': 'с {date}',
'sp.remove': 'Убрать',
'sp.removeManagerConfirm': 'Убрать {name} из менеджеров партнёра?',
'sp.staff': 'Сотрудники',
'sp.staffHint': 'добавляет менеджер партнёра',
'sp.noStaff': 'Сотрудников пока нет.',
'sp.limitOrders': 'до {n} заказов в день',
'sp.limitHours': 'до {n} ч в день',
'sp.notLinked': 'не вошёл в бот',
'sp.off': 'выключен',
'sp.orders': 'Заказы в работе',
'sp.noOrders': 'Сейчас заказов у партнёра нет.',
'sp.waitingStaff': 'ждёт сотрудника',
'sp.toPay': 'Доход партнёра к выплате',
'sp.byOrder': 'Доход по заказам',
'sp.noIncome': 'Доход появится, когда клиент оплатит выполненный заказ.',
'sp.paid': 'оплачено {sum}',
'sp.fotTax': 'ФОТ {fot}, налог {tax}',
'sp.payoutTitle': 'Выплата партнёру',
'sp.phone': 'Телефон',
'sp.service': 'Услуга',
'sp.cleaningOnly': 'Клининг (пока только он)',
'sp.deal': 'Модель сотрудничества',
'sp.deal.COMMISSION': 'Как Uber: наш % с заказа',
'sp.deal.PROFIT_SHARE': 'Расходы наши: % партнёру',
'sp.dealHint.COMMISSION': 'Мы забираем свой % с оплаченного заказа, остальное — партнёру. Зарплаты сотрудникам и расходы — его.',
'sp.dealHint.PROFIT_SHARE': 'ФОТ клинеру и налог платим мы; партнёру — его % от того, что осталось: оплачено − ФОТ − налог.',
'sp.commissionPct': 'Наш %, с оплаченного заказа',
'sp.profitSharePct': '% партнёра от прибыли',
'sp.fotPct': 'ФОТ клинера, % от заказа',
'sp.taxPct': 'Налог, %',
'sp.defaultHint': 'пусто — из настроек ({n}%)',
'sp.exampleCOMMISSION': 'Пример: заказ оплачен на {sum} → нам {ours}, партнёру {partner}.',
'sp.examplePROFIT_SHARE': 'Пример: оплачено {sum} − ФОТ {fot} − налог {tax} = {profit}; партнёру {partner}, нам {ours}.',
'sp.activeLabel': 'Партнёр работает (можно назначать заказы)',
'nav.points': 'Барики',
'st.group.points': 'Барики (кэшбек)',
'ag.hwPoints': 'Барики на новоселье',
'ag.hwDays': 'Срок, дней',
'ag.hwHint': '0 — не дарим. Начисляются один раз, сразу доступны.',
'ag.hwValue': '{n} бариков на {days} дн.',
'pt.title': 'Барики',
'pt.tab.accrue': 'Начислить',
'pt.tab.requests': 'Запросы',
'pt.tab.suggestions': 'Предложено системой',
'pt.tab.ledger': 'Журнал',
'pt.tab.report': 'Отчёт',
'pt.tab.import': 'Импорт CSV',
'pt.tab.setup': 'Ставки и бонусы',
'pt.search': 'Клиент',
'pt.searchPh': 'Телефон, @username, имя или ID',
'pt.notFound': 'Никого не нашли',
'pt.pickClient': 'Найдите клиента слева — здесь появится форма начисления.',
'pt.flagged': 'проверить: много отмен',
'pt.balance': 'Доступно',
'pt.pending': 'В ожидании',
'pt.availableFrom': 'с {date}',
'pt.tier': 'Уровень',
'pt.burnAt': 'Сгорят',
'pt.agency': 'Агентство',
'pt.streak': 'Серия уборок',
'pt.debt': 'Долг',
'pt.redeem': 'Списать',
'pt.redeemBtn': 'Списать',
'pt.redeemed': 'Списано',
'pt.allOps': 'Все операции',
'pt.noOps': 'Операций пока нет.',
'pt.reverse': 'Отменить',
'pt.accrueTitle': 'Начисление',
'pt.kind.ORDER': 'За заказ',
'pt.kind.BONUS': 'Бонус',
'pt.f.category': 'Категория',
'pt.f.orderAmount': 'Сумма заказа, ֏',
'pt.f.excluded': 'Не облагается, ֏',
'pt.f.excludedHint': 'пошлины, материалы, чаевые',
'pt.f.paidWithPoints': 'Оплачено бариками',
'pt.f.serviceDate': 'Дата услуги',
'pt.f.orderRef': 'Номер заказа',
'pt.f.orderRefHint': 'второй раз по тому же номеру начислить нельзя',
'pt.f.bonusType': 'Тип бонуса',
'pt.f.ttl': 'Срок жизни, дней',
'pt.f.ttlDefault': 'по умолчанию {n}',
'pt.f.ttlNone': 'пусто — общее правило сгорания',
'pt.f.amount': 'Сумма бариков',
'pt.f.amountHint': 'подсказка: {n}',
'pt.f.reason': 'Причина',
'pt.f.reasonRequired': 'Причина (обязательно)',
'pt.f.comment': 'Комментарий для клиента',
'pt.f.commentPh': 'Увидит в истории',
'pt.f.noWait': 'Без ожидания — сразу доступны',
'pt.f.noWaitReason': 'Без ожидания (нужна причина)',
'pt.f.redeemAmount': 'Сколько списать',
'pt.f.defaultAmount': 'Сумма по умолчанию',
'pt.formula': '{tier}: {rate}%{extra} → {n} бариков',
'pt.accrueBtn': 'Начислить',
'pt.confirmTitle': 'Проверьте',
'pt.edit': 'Изменить',
'pt.confirmOrder': 'Начислить {n} бариков {name} за {what} {sum} ֏ ({tier}, {rate}%). Станут доступны {date}.',
'pt.confirmBonus': 'Начислить {n} бариков {name}: {what}, {until}.',
'pt.now': 'сразу',
'pt.forDays': 'на {n} дн.',
'pt.noTtl': 'без своего срока',
'pt.changedNote': 'Сумма изменена против подсказки ({n}) — попадёт в журнал с вашей причиной.',
'pt.accrued': 'Начислено {n}',
'pt.needsApproval': 'Выше лимита — отправлено суперадмину на подтверждение',
'pt.zero': 'Сумма 0 — начислять нечего',
'pt.reverseAccrual': 'Отменить начисление',
'pt.reverseRedemption': 'Отменить списание',
'pt.reverseAccrualText': 'Начисление {n} бариков будет отменено. Исходная запись останется в журнале.',
'pt.reverseRedemptionText': '{n} бариков вернутся в исходные начисления с их сроками.',
'pt.debtWarn': 'Из начисления уже потрачено {spent} — баланс уйдёт в минус: {after}. Минус погасится следующими начислениями.',
'pt.reasonPh': 'Возврат денег, ошибка…',
'pt.reverseHint': 'Поддержка отменяет только свои операции и только в течение 24 часов.',
'pt.reversed': 'Отменено',
'pt.maxHint': 'максимум {max} ({share}% заказа и не больше баланса), минимум {min}',
'pt.redeemReasonPh': 'По звонку, заказ вне приложения…',
'pt.toPay': 'К доплате: {sum} ֏',
'pt.noRequests': 'Запросов нет.',
'pt.wants': 'хочет списать {n}',
'pt.forOrder': 'заказ №{n}',
'pt.noOrder': 'заказ вне приложения',
'pt.balanceN': 'баланс {n}',
'pt.confirm': 'Подтвердить',
'pt.confirmN': 'Списать {n}',
'pt.confirmed': 'Списано {n}',
'pt.reject': 'Отклонить',
'pt.rejectReason': 'Причина — её увидит клиент',
'pt.noSuggestions': 'Предложений нет.',
'pt.suggestionsHint': 'Система нашла, кому положен бонус. Начислите, измените сумму или отклоните.',
'pt.mech.first_month_route': 'Маршрут первого месяца',
'pt.mech.photo_review': 'Отзыв с фото',
'pt.mech.over_limit': 'Выше лимита поддержки',
'pt.why.first_month_route': '3 разные категории за первые 30 дней',
'pt.why.photo_review': 'прислал фото после заказа №{n}',
'pt.why.over_limit': '{by}: {op}',
'pt.op.accrual': 'начисление',
'pt.op.redemption': 'списание',
'pt.approve': 'Подтвердить',
'pt.changeAmount': 'Изменить сумму',
'pt.allTypes': 'Все типы',
'pt.allCategories': 'Все категории',
'pt.allChannels': 'Все каналы',
'pt.allClients': 'Все клиенты',
'pt.type.accrual': 'Начисление',
'pt.type.bonus': 'Бонус',
'pt.type.redemption': 'Списание',
'pt.type.request': 'Запрос клиента',
'pt.type.reversal': 'Отмена',
'pt.channel.WEB': 'админка',
'pt.channel.BOT': 'бот',
'pt.channel.CSV': 'CSV',
'pt.channel.AUTO': 'авто',
'pt.channel.CLIENT': 'клиент',
'pt.status.PENDING': 'в ожидании',
'pt.status.ACTIVE': 'активно',
'pt.status.SPENT': 'потрачено',
'pt.status.EXPIRED': 'сгорело',
'pt.status.REVERSED': 'отменено',
'pt.status.REQUESTED': 'ждёт',
'pt.status.CONFIRMED': 'списано',
'pt.status.REJECTED': 'отклонено',
'pt.status.ACCRUAL': 'начисления',
'pt.status.REDEMPTION': 'списания',
'pt.col.date': 'Когда',
'pt.col.client': 'Клиент',
'pt.col.what': 'Что',
'pt.col.amount': 'Барики',
'pt.col.status': 'Статус',
'pt.col.author': 'Кто',
'pt.system': 'система',
'pt.suggestedN': 'подсказка {n}',
'pt.debtN': 'в долг {n}',
'pt.rep.accrued': 'Начислено',
'pt.rep.spent': 'Потрачено',
'pt.rep.expired': 'Сгорело',
'pt.rep.reversed': 'Отменено',
'pt.rep.liabilities': 'Обязательства сейчас',
'pt.rep.rate': 'Доля потраченных',
'pt.rep.byCategory': 'Стоимость программы по категориям',
'pt.rep.cost': '{pct}% от выручки {revenue} ֏',
'pt.rep.retention': 'Повторный заказ за 30 дней',
'pt.rep.with': 'С бариками',
'pt.rep.without': 'Без бариков',
'pt.rep.clientsN': 'клиентов {n}, с 2+ категориями {multi}',
'pt.rep.topClients': 'Топ по балансу',
'pt.rep.topAgencies': 'Агентства: активации новоселья',
'pt.importHint': 'CSV: телефон, категория (cleaning, documents, learning, repair, transfer), сумма заказа, дата (ГГГГ-ММ-ДД), номер заказа. Сначала — предпросмотр, начисление только после подтверждения.',
'pt.importRows': 'Строк',
'pt.importTotal': 'Бариков всего',
'pt.importErr.client': 'клиент не найден',
'pt.importErr.category': 'нет такой категории',
'pt.importErr.date': 'неверная дата',
'pt.importErr.duplicate': 'дубль заказа',
'pt.importCommit': 'Начислить {n} строк',
'pt.importDone': 'Начислено по {n} строкам',
'pt.categories': 'Категории и ставки',
'pt.bonusTypes': 'Типы бонусов',
'pt.code': 'Код',
'pt.activeLabel': 'Работает',
'pt.rulesHint': 'Доступны через {hours} ч после услуги · оплатить можно до {share}% заказа, минимум {min} · сгорают через {burn} дней без активности. Меняется в «Настройках».',
'err.POINTS_DUPLICATE_ORDER': 'За этот заказ барики уже начислены.',
'err.POINTS_REASON_REQUIRED': 'Укажите причину.',
'err.POINTS_BELOW_MIN': 'Меньше минимальной суммы списания.',
'err.POINTS_NOT_ENOUGH': 'Не хватает бариков.',
'err.POINTS_OVER_SHARE': 'Больше допустимой доли заказа.',
'err.POINTS_OVER_LIMIT': 'Выше вашего лимита — нужно подтверждение суперадмина.',
'err.POINTS_FINAL': 'Эту операцию уже нельзя отменить.',
'err.POINTS_REQUEST_CLOSED': 'Запрос уже обработан или истёк.',
'err.POINTS_WITH_PROMO': 'Барики нельзя вместе с промокодом.',
'pt.formulaStreak': ' + серия уборок {streak}: +{bonus}%',
'ord.editComposition': 'Изменить состав',
'ord.editTitle': 'Состав заказа №{n}',
'ord.editPrice': 'Цена: было {was} → станет {now} (скидка {pct}%, как при оформлении)',
'ord.editDuration': 'Длительность: {was} → {now} мин',
'ord.editBusy': 'Заказ стал дольше, а у исполнителя в это время другой заказ — проверьте график.',
'ord.editPaid': 'Заказ уже оплачен: пересчитаем вознаграждение агентства, доход партнёра и барики. Разницу в деньгах решите с клиентом.',
'ord.editOwnPrice': 'Своя итоговая цена, ֏',
'ord.editOwnPriceHint': 'пусто — по прайсу: {n}',
'ord.editReason': 'Причина',
'ord.editReasonRequired': 'Причина (обязательно для своей цены)',
'ord.editReasonPh': 'На месте добавили окна…',
'ord.editNotify': 'Сообщить клиенту и исполнителю',
'ord.edited': 'Состав заказа изменён',
'ord.editedBusy': 'Изменено. Внимание: исполнитель может не успеть — проверьте график',
'ord.uncheck': 'Снять отметку',
'ord.checkForProvider': 'Отметить за исполнителя',
'ord.noPhotoReason': 'без фото: {reason}',
'ord.byStaff': 'отметила команда',
'ord.checkNoPhoto': 'Отметить без фото',
'ord.photoHint': 'Для этого пункта нужно фото. Загрузите его или отметьте без фото с причиной.',
'ord.uploadPhoto': 'Загрузить фото',
'ord.noPhotoField': 'Или причина, почему без фото',
'ord.noPhotoPh': 'Клинер прислал фото в чат…',
'err.PHOTO_REQUIRED': 'Нужно фото или причина, почему без него.',
};
export type BoKey = keyof typeof ru;
@@ -1211,8 +1484,8 @@ const en: Dict = {
'nav.chats': 'Chats',
'nav.orders': 'Orders',
'nav.providers': 'Providers',
'nav.agencies': 'Agencies',
'nav.venues': 'Venues',
'nav.agencies': 'Real estate agencies',
'nav.venues': 'Club partners',
'nav.crm': 'CRM',
'nav.products': 'Products',
'nav.journal': 'Activity log',
@@ -1824,7 +2097,7 @@ const en: Dict = {
'st.saveN': 'Save ({n})',
'st.group.orders': 'Orders',
'st.group.subs': 'Membership',
'st.group.agencies': 'Agencies',
'st.group.agencies': 'Real estate agencies',
'st.group.sales': 'Sales bonus',
'st.group.site': 'Website prices',
'st.group.other': 'Other',
@@ -1977,22 +2250,22 @@ const en: Dict = {
'venue.cat.shop': 'Shop',
'venue.cat.beauty': 'Beauty',
'venue.cat.other': 'Other',
'ag.title': 'Agencies',
'ag.add': 'New agency',
'ag.editTitle': 'Agency',
'ag.empty': 'No agencies yet.',
'ag.col.name': 'Agency',
'ag.title': 'Real estate agencies',
'ag.add': 'New real estate agency',
'ag.editTitle': 'Real estate agency',
'ag.empty': 'No real estate agencies yet.',
'ag.col.name': 'Real estate agency',
'ag.col.clients': 'Clients',
'ag.col.gifts': 'Gifts',
'ag.col.month': 'Reward, month',
'ag.col.balance': 'To pay',
'ag.hint': 'Reward = % of paid orders of the agency’s clients while they’re attached.',
'ag.inactive': 'inactive',
'ag.back': 'All agencies',
'ag.back': 'All real estate agencies',
'ag.pct': '{n}% of client orders',
'ag.byS': 'connected by {name}',
'ag.link': 'Client link',
'ag.linkHint': 'The client opens the bot, gets a gift month and is attached to the agency. Or types code {code} via /code.',
'ag.linkHint': 'The client opens the bot, gets the agency gift and is attached to the agency. Or types code {code} via /code.',
'ag.clients': 'Clients',
'ag.giftsN': 'gifts: {n}',
'ag.noClients': 'No clients yet.',
@@ -2006,7 +2279,7 @@ const en: Dict = {
'ag.sales': 'Connected by',
'ag.contactPh': 'Name, phone, Telegram',
'ag.active': 'Agency active (code accepted)',
'vn.title': 'Venues',
'vn.title': 'Club partners',
'vn.add': 'New venue',
'vn.editTitle': 'Venue',
'vn.empty': 'No venues yet.',
@@ -2337,6 +2610,279 @@ const en: Dict = {
'audit.bo.chats.openFor': 'opened a chat with a client',
'audit.bo.chats.templates.save': 'edited a reply template',
'audit.bo.chats.templates.remove': 'deleted a reply template',
'nav.servicePartners': 'Service partners',
'st.group.partners': 'Service partners',
'ag.giftPlan': 'Gift for new clients',
'ag.giftMonths': 'For how long',
'ag.giftHint': 'Applies to new clients; those who already got a gift keep theirs.',
'ag.noGift': 'no gift',
'ag.giftValue': '{plan} for {n} mo',
'ag.monthsN': '{n} mo',
'ord.partner': 'partner',
'ord.best': 'best for us',
'ord.partnerFree': '{n} of {of} free',
'ord.candMoney': 'We pay {cost} · we keep {margin}',
'prv.ratePartnerPays': 'Paid by the partner',
'rq.missing': 'missing details',
'rq.noTime': 'not given',
'rq.noDistrict': 'no district',
'rq.noAddress': 'no address',
'sp.title': 'Service partners',
'sp.add': 'New partner',
'sp.editTitle': 'Service partner',
'sp.lead': 'Cleaning companies with their own staff. The partner’s manager gets a link, adds staff and assigns orders. An operator gives an order to a partner from the order card.',
'sp.empty': 'No partners yet.',
'sp.col.name': 'Partner',
'sp.col.staff': 'Staff',
'sp.col.active': 'Active orders',
'sp.col.month': 'Partner income, month',
'sp.col.balance': 'To pay',
'sp.noManager': 'no manager',
'sp.auto': 'auto-assign',
'sp.back': 'All service partners',
'sp.terms.COMMISSION': 'Uber-style: we take {pct}%',
'sp.terms.PROFIT_SHARE': 'our costs: payroll {fot}%, tax {tax}%, partner gets {pct}% of profit',
'sp.managerLink': 'Manager link',
'sp.managerLinkHint': 'Give this link to the partner’s manager: they open the work bot and get the cabinet — orders, staff, auto-assignment, income. It also opens on a computer (button in the bot).',
'sp.resetLink': 'New link',
'sp.resetConfirm': 'Make a new link? The old one stops working; connected managers stay.',
'sp.linkReset': 'Link updated',
'sp.managers': 'Managers',
'sp.noManagers': 'No manager has joined via the link yet.',
'sp.managerSince': 'since {date}',
'sp.remove': 'Remove',
'sp.removeManagerConfirm': 'Remove {name} from the partner’s managers?',
'sp.staff': 'Staff',
'sp.staffHint': 'added by the partner’s manager',
'sp.noStaff': 'No staff yet.',
'sp.limitOrders': 'up to {n} orders a day',
'sp.limitHours': 'up to {n} h a day',
'sp.notLinked': 'not in the bot',
'sp.off': 'off',
'sp.orders': 'Active orders',
'sp.noOrders': 'The partner has no orders right now.',
'sp.waitingStaff': 'waiting for staff',
'sp.toPay': 'Partner income to pay',
'sp.byOrder': 'Income by order',
'sp.noIncome': 'Income appears once a client pays for a completed order.',
'sp.paid': 'paid {sum}',
'sp.fotTax': 'payroll {fot}, tax {tax}',
'sp.payoutTitle': 'Payout to partner',
'sp.phone': 'Phone',
'sp.service': 'Service',
'sp.cleaningOnly': 'Cleaning (only this for now)',
'sp.deal': 'Deal model',
'sp.deal.COMMISSION': 'Uber-style: our % of order',
'sp.deal.PROFIT_SHARE': 'Our costs: % to partner',
'sp.dealHint.COMMISSION': 'We keep our % of each paid order, the rest goes to the partner. Wages and costs are theirs.',
'sp.dealHint.PROFIT_SHARE': 'We pay the cleaner and the tax; the partner gets their % of what’s left: paid − payroll − tax.',
'sp.commissionPct': 'Our %, of paid order',
'sp.profitSharePct': 'Partner’s % of profit',
'sp.fotPct': 'Cleaner payroll, % of order',
'sp.taxPct': 'Tax, %',
'sp.defaultHint': 'empty — from settings ({n}%)',
'sp.exampleCOMMISSION': 'Example: order paid {sum} → we keep {ours}, partner gets {partner}.',
'sp.examplePROFIT_SHARE': 'Example: paid {sum} − payroll {fot} − tax {tax} = {profit}; partner {partner}, us {ours}.',
'sp.activeLabel': 'Partner active (orders can be assigned)',
'nav.points': 'Bariki',
'st.group.points': 'Bariki (cashback)',
'ag.hwPoints': 'Housewarming bariki',
'ag.hwDays': 'Valid, days',
'ag.hwHint': '0 — none. Credited once, available right away.',
'ag.hwValue': '{n} bariki for {days} days',
'pt.title': 'Bariki',
'pt.tab.accrue': 'Credit',
'pt.tab.requests': 'Requests',
'pt.tab.suggestions': 'Suggested',
'pt.tab.ledger': 'Log',
'pt.tab.report': 'Report',
'pt.tab.import': 'CSV import',
'pt.tab.setup': 'Rates & bonuses',
'pt.search': 'Client',
'pt.searchPh': 'Phone, @username, name or ID',
'pt.notFound': 'No one found',
'pt.pickClient': 'Find a client on the left — the credit form appears here.',
'pt.flagged': 'check: many reversals',
'pt.balance': 'Available',
'pt.pending': 'Pending',
'pt.availableFrom': 'from {date}',
'pt.tier': 'Level',
'pt.burnAt': 'Expire on',
'pt.agency': 'Agency',
'pt.streak': 'Cleaning streak',
'pt.debt': 'Debt',
'pt.redeem': 'Redeem',
'pt.redeemBtn': 'Redeem',
'pt.redeemed': 'Redeemed',
'pt.allOps': 'All operations',
'pt.noOps': 'No operations yet.',
'pt.reverse': 'Reverse',
'pt.accrueTitle': 'Credit',
'pt.kind.ORDER': 'For an order',
'pt.kind.BONUS': 'Bonus',
'pt.f.category': 'Category',
'pt.f.orderAmount': 'Order amount, ֏',
'pt.f.excluded': 'Not eligible, ֏',
'pt.f.excludedHint': 'fees, materials, tips',
'pt.f.paidWithPoints': 'Paid with bariki',
'pt.f.serviceDate': 'Service date',
'pt.f.orderRef': 'Order number',
'pt.f.orderRefHint': 'the same number can’t be credited twice',
'pt.f.bonusType': 'Bonus type',
'pt.f.ttl': 'Valid for, days',
'pt.f.ttlDefault': 'default {n}',
'pt.f.ttlNone': 'empty — general expiry rule',
'pt.f.amount': 'Bariki amount',
'pt.f.amountHint': 'suggested: {n}',
'pt.f.reason': 'Reason',
'pt.f.reasonRequired': 'Reason (required)',
'pt.f.comment': 'Comment for the client',
'pt.f.commentPh': 'Shown in their history',
'pt.f.noWait': 'No waiting — available now',
'pt.f.noWaitReason': 'No waiting (reason required)',
'pt.f.redeemAmount': 'How much to redeem',
'pt.f.defaultAmount': 'Default amount',
'pt.formula': '{tier}: {rate}%{extra} → {n} bariki',
'pt.accrueBtn': 'Credit',
'pt.confirmTitle': 'Please check',
'pt.edit': 'Edit',
'pt.confirmOrder': 'Credit {n} bariki to {name} for {what} {sum} ֏ ({tier}, {rate}%). Available {date}.',
'pt.confirmBonus': 'Credit {n} bariki to {name}: {what}, {until}.',
'pt.now': 'right away',
'pt.forDays': 'for {n} days',
'pt.noTtl': 'no own expiry',
'pt.changedNote': 'Amount differs from the suggestion ({n}) — logged with your reason.',
'pt.accrued': 'Credited {n}',
'pt.needsApproval': 'Above the limit — sent to a super admin',
'pt.zero': 'Amount is 0 — nothing to credit',
'pt.reverseAccrual': 'Reverse credit',
'pt.reverseRedemption': 'Reverse redemption',
'pt.reverseAccrualText': 'The credit of {n} bariki will be reversed. The original stays in the log.',
'pt.reverseRedemptionText': '{n} bariki return to the original credits with their expiry.',
'pt.debtWarn': '{spent} of this credit is already spent — balance goes negative: {after}. Future credits will cover it.',
'pt.reasonPh': 'Refund, mistake…',
'pt.reverseHint': 'Support can reverse only their own operations within 24 hours.',
'pt.reversed': 'Reversed',
'pt.maxHint': 'max {max} ({share}% of the order, within balance), min {min}',
'pt.redeemReasonPh': 'By phone, order outside the app…',
'pt.toPay': 'Left to pay: {sum} ֏',
'pt.noRequests': 'No requests.',
'pt.wants': 'wants to use {n}',
'pt.forOrder': 'order #{n}',
'pt.noOrder': 'order outside the app',
'pt.balanceN': 'balance {n}',
'pt.confirm': 'Confirm',
'pt.confirmN': 'Redeem {n}',
'pt.confirmed': 'Redeemed {n}',
'pt.reject': 'Decline',
'pt.rejectReason': 'Reason — the client will see it',
'pt.noSuggestions': 'No suggestions.',
'pt.suggestionsHint': 'The system found who qualifies for a bonus. Credit, change the amount or decline.',
'pt.mech.first_month_route': 'First-month route',
'pt.mech.photo_review': 'Photo review',
'pt.mech.over_limit': 'Above support limit',
'pt.why.first_month_route': '3 different categories in the first 30 days',
'pt.why.photo_review': 'sent a photo after order #{n}',
'pt.why.over_limit': '{by}: {op}',
'pt.op.accrual': 'credit',
'pt.op.redemption': 'redemption',
'pt.approve': 'Approve',
'pt.changeAmount': 'Change amount',
'pt.allTypes': 'All types',
'pt.allCategories': 'All categories',
'pt.allChannels': 'All channels',
'pt.allClients': 'All clients',
'pt.type.accrual': 'Credit',
'pt.type.bonus': 'Bonus',
'pt.type.redemption': 'Redemption',
'pt.type.request': 'Client request',
'pt.type.reversal': 'Reversal',
'pt.channel.WEB': 'admin',
'pt.channel.BOT': 'bot',
'pt.channel.CSV': 'CSV',
'pt.channel.AUTO': 'auto',
'pt.channel.CLIENT': 'client',
'pt.status.PENDING': 'pending',
'pt.status.ACTIVE': 'active',
'pt.status.SPENT': 'spent',
'pt.status.EXPIRED': 'expired',
'pt.status.REVERSED': 'reversed',
'pt.status.REQUESTED': 'waiting',
'pt.status.CONFIRMED': 'redeemed',
'pt.status.REJECTED': 'declined',
'pt.status.ACCRUAL': 'of a credit',
'pt.status.REDEMPTION': 'of a redemption',
'pt.col.date': 'When',
'pt.col.client': 'Client',
'pt.col.what': 'What',
'pt.col.amount': 'Bariki',
'pt.col.status': 'Status',
'pt.col.author': 'Who',
'pt.system': 'system',
'pt.suggestedN': 'suggested {n}',
'pt.debtN': 'debt {n}',
'pt.rep.accrued': 'Credited',
'pt.rep.spent': 'Spent',
'pt.rep.expired': 'Expired',
'pt.rep.reversed': 'Reversed',
'pt.rep.liabilities': 'Outstanding now',
'pt.rep.rate': 'Redemption rate',
'pt.rep.byCategory': 'Programme cost by category',
'pt.rep.cost': '{pct}% of {revenue} ֏ revenue',
'pt.rep.retention': 'Repeat order within 30 days',
'pt.rep.with': 'With bariki',
'pt.rep.without': 'Without bariki',
'pt.rep.clientsN': '{n} clients, {multi} with 2+ categories',
'pt.rep.topClients': 'Top by balance',
'pt.rep.topAgencies': 'Agencies: housewarming activations',
'pt.importHint': 'CSV: phone, category (cleaning, documents, learning, repair, transfer), order amount, date (YYYY-MM-DD), order number. Preview first; credited only after you confirm.',
'pt.importRows': 'Rows',
'pt.importTotal': 'Total bariki',
'pt.importErr.client': 'client not found',
'pt.importErr.category': 'unknown category',
'pt.importErr.date': 'bad date',
'pt.importErr.duplicate': 'duplicate order',
'pt.importCommit': 'Credit {n} rows',
'pt.importDone': 'Credited {n} rows',
'pt.categories': 'Categories and rates',
'pt.bonusTypes': 'Bonus types',
'pt.code': 'Code',
'pt.activeLabel': 'Active',
'pt.rulesHint': 'Available {hours} h after the service · up to {share}% of an order, min {min} · expire after {burn} days of inactivity. Change in Settings.',
'err.POINTS_DUPLICATE_ORDER': 'Bariki for this order were already credited.',
'err.POINTS_REASON_REQUIRED': 'Please give a reason.',
'err.POINTS_BELOW_MIN': 'Below the minimum redemption.',
'err.POINTS_NOT_ENOUGH': 'Not enough bariki.',
'err.POINTS_OVER_SHARE': 'More than the allowed share of the order.',
'err.POINTS_OVER_LIMIT': 'Above your limit — needs a super admin.',
'err.POINTS_FINAL': 'This can no longer be reversed.',
'err.POINTS_REQUEST_CLOSED': 'Request already handled or expired.',
'err.POINTS_WITH_PROMO': 'Bariki can’t be combined with a promo code.',
'pt.formulaStreak': ' + cleaning streak {streak}: +{bonus}%',
'ord.editComposition': 'Edit composition',
'ord.editTitle': 'Order #{n} composition',
'ord.editPrice': 'Price: was {was} → now {now} ({pct}% discount, as when booked)',
'ord.editDuration': 'Duration: {was} → {now} min',
'ord.editBusy': 'The order got longer and the provider has another job then — check the schedule.',
'ord.editPaid': 'Already paid: agency reward, partner income and bariki will be recalculated. Settle the money difference with the client.',
'ord.editOwnPrice': 'Custom total, ֏',
'ord.editOwnPriceHint': 'empty — price list: {n}',
'ord.editReason': 'Reason',
'ord.editReasonRequired': 'Reason (required for a custom price)',
'ord.editReasonPh': 'Added windows on site…',
'ord.editNotify': 'Notify the client and the provider',
'ord.edited': 'Order updated',
'ord.editedBusy': 'Updated. The provider may not have time — check the schedule',
'ord.uncheck': 'Uncheck',
'ord.checkForProvider': 'Check for the provider',
'ord.noPhotoReason': 'no photo: {reason}',
'ord.byStaff': 'checked by the team',
'ord.checkNoPhoto': 'Check without photo',
'ord.photoHint': 'This item needs a photo. Upload one or check it without a photo and a reason.',
'ord.uploadPhoto': 'Upload photo',
'ord.noPhotoField': 'Or why there is no photo',
'ord.noPhotoPh': 'The cleaner sent it in the chat…',
'err.PHOTO_REQUIRED': 'A photo or a reason is required.',
};
const hy: Dict = {
@@ -2376,8 +2922,8 @@ const hy: Dict = {
'nav.chats': 'Չատեր',
'nav.orders': 'Պատվերներ',
'nav.providers': 'Կատարողներ',
'nav.agencies': 'Գործակալություններ',
'nav.venues': 'Հաստատություններ',
'nav.agencies': 'Անշարժ գույքի գործակալություններ',
'nav.venues': 'Ակումբի գործընկերներ',
'nav.crm': 'CRM',
'nav.products': 'Ապրանքներ',
'nav.journal': 'Մատյան',
@@ -2989,7 +3535,7 @@ const hy: Dict = {
'st.saveN': 'Պահպանել ({n})',
'st.group.orders': 'Պատվերներ',
'st.group.subs': 'Բաժանորդագրություն',
'st.group.agencies': 'Գործակալություններ',
'st.group.agencies': 'Անշարժ գույքի գործակալություններ',
'st.group.sales': 'Վաճառողների բոնուս',
'st.group.site': 'Կայքի գներ',
'st.group.other': 'Այլ',
@@ -3142,10 +3688,10 @@ const hy: Dict = {
'venue.cat.shop': 'Խանութ',
'venue.cat.beauty': 'Գեղեցկություն',
'venue.cat.other': 'Այլ',
'ag.title': 'Գործակալություններ',
'ag.add': 'Նոր գործակալություն',
'ag.editTitle': 'Գործակալություն',
'ag.empty': 'Գործակալություններ դեռ չկան։',
'ag.title': 'Անշարժ գույքի գործակալություններ',
'ag.add': 'Նոր անշարժ գույքի գործակալություն',
'ag.editTitle': 'Անշարժ գույքի գործակալություն',
'ag.empty': 'Անշարժ գույքի գործակալություններ դեռ չկան։',
'ag.col.name': 'Գործակալություն',
'ag.col.clients': 'Հաճախորդներ',
'ag.col.gifts': 'Նվերներ',
@@ -3157,7 +3703,7 @@ const hy: Dict = {
'ag.pct': '{n}% հաճախորդների պատվերներից',
'ag.byS': 'միացրել է {name}',
'ag.link': 'Հղում հաճախորդների համար',
'ag.linkHint': 'Հաճախորդը բացում է բոտը և ստանում նվեր ամիս։ Կամ մուտքագրում է {code} կոդը։',
'ag.linkHint': 'Հաճախորդը բացում է բոտը, ստանում գործակալության նվերը և կցվում նրան։ Կամ մուտքագրում է {code} կոդը /code հրամանով։',
'ag.clients': 'Հաճախորդներ',
'ag.giftsN': 'նվերներ՝ {n}',
'ag.noClients': 'Հաճախորդներ դեռ չկան։',
@@ -3170,8 +3716,8 @@ const hy: Dict = {
'ag.pctLabel': 'Պարգև, %',
'ag.sales': 'Միացրել է',
'ag.contactPh': 'Անուն, հեռախոս, Telegram',
'ag.active': 'Գործակալությունն աշխատում է',
'vn.title': 'Հաստատություններ',
'ag.active': 'Գործակալությունն աշխատում է (կոդն ընդունվում է)',
'vn.title': 'Ակումբի գործընկերներ',
'vn.add': 'Նոր հաստատություն',
'vn.editTitle': 'Հաստատություն',
'vn.empty': 'Հաստատություններ դեռ չկան։',
@@ -3502,6 +4048,279 @@ const hy: Dict = {
'audit.bo.chats.openFor': 'բացեց զրույց հաճախորդի հետ',
'audit.bo.chats.templates.save': 'փոխեց պատասխանի ձևանմուշը',
'audit.bo.chats.templates.remove': 'ջնջեց պատասխանի ձևանմուշը',
'nav.servicePartners': 'Ծառայությունների գործընկերներ',
'st.group.partners': 'Ծառայությունների գործընկերներ',
'ag.giftPlan': 'Նվեր նոր հաճախորդներին',
'ag.giftMonths': 'Որքան ժամանակով',
'ag.giftHint': 'Գործում է նոր հաճախորդների համար․ արդեն նվեր ստացածների մոտ ոչինչ չի փոխվում։',
'ag.noGift': 'առանց նվերի',
'ag.giftValue': '{plan}՝ {n} ամիս',
'ag.monthsN': '{n} ամիս',
'ord.partner': 'գործընկեր',
'ord.best': 'ամենաշահավետը',
'ord.partnerFree': 'ազատ է {n}-ը {of}-ից',
'ord.candMoney': 'Վճարում ենք {cost} · մեզ մնում է {margin}',
'prv.ratePartnerPays': 'Աշխատավարձը վճարում է գործընկերը',
'rq.missing': 'տվյալներ չեն բավարարում',
'rq.noTime': 'նշված չէ',
'rq.noDistrict': 'շրջանը նշված չէ',
'rq.noAddress': 'հասցեն նշված չէ',
'sp.title': 'Ծառայությունների գործընկերներ',
'sp.add': 'Նոր գործընկեր',
'sp.editTitle': 'Ծառայությունների գործընկեր',
'sp.lead': 'Մաքրման ընկերություններ՝ իրենց աշխատակիցներով։ Գործընկերոջ մենեջերը ստանում է հղում, ինքն է ավելացնում աշխատակիցներին և բաշխում պատվերները։ Պատվերը գործընկերոջը նշանակում է օպերատորը։',
'sp.empty': 'Գործընկերներ դեռ չկան։',
'sp.col.name': 'Գործընկեր',
'sp.col.staff': 'Աշխատակիցներ',
'sp.col.active': 'Ընթացիկ պատվերներ',
'sp.col.month': 'Գործընկերոջ եկամուտը, ամիս',
'sp.col.balance': 'Վճարման ենթակա',
'sp.noManager': 'մենեջեր չկա',
'sp.auto': 'ավտոբաշխում',
'sp.back': 'Բոլոր գործընկերները',
'sp.terms.COMMISSION': 'Uber-ի պես՝ մեր {pct}%-ը',
'sp.terms.PROFIT_SHARE': 'ծախսերը մերն են՝ աշխ. {fot}%, հարկ {tax}%, գործընկերոջը՝ շահույթի {pct}%',
'sp.managerLink': 'Մենեջերի հղումը',
'sp.managerLinkHint': 'Տվեք հղումը գործընկերոջ մենեջերին․ նա կբացի աշխատանքային բոտը և կստանա կաբինետ՝ պատվերներ, աշխատակիցներ, ավտոբաշխում, եկամուտ։ Կաբինետը բացվում է նաև համակարգչում (կոճակը բոտում)։',
'sp.resetLink': 'Նոր հղում',
'sp.resetConfirm': 'Ստեղծե՞լ նոր հղում։ Հինը կդադարի աշխատել, միացված մենեջերները կմնան։',
'sp.linkReset': 'Հղումը թարմացված է',
'sp.managers': 'Մենեջերներ',
'sp.noManagers': 'Մենեջերը դեռ չի միացել հղումով։',
'sp.managerSince': '{date}-ից',
'sp.remove': 'Հեռացնել',
'sp.removeManagerConfirm': 'Հեռացնե՞լ {name}-ին մենեջերներից։',
'sp.staff': 'Աշխատակիցներ',
'sp.staffHint': 'ավելացնում է գործընկերոջ մենեջերը',
'sp.noStaff': 'Աշխատակիցներ դեռ չկան։',
'sp.limitOrders': 'օրական մինչև {n} պատվեր',
'sp.limitHours': 'օրական մինչև {n} ժ',
'sp.notLinked': 'բոտ չի մտել',
'sp.off': 'անջատված',
'sp.orders': 'Ընթացիկ պատվերներ',
'sp.noOrders': 'Գործընկերը հիմա պատվեր չունի։',
'sp.waitingStaff': 'սպասում է աշխատակցի',
'sp.toPay': 'Գործընկերոջ եկամուտը վճարման',
'sp.byOrder': 'Եկամուտն ըստ պատվերների',
'sp.noIncome': 'Եկամուտը կհայտնվի, երբ հաճախորդը վճարի կատարված պատվերի համար։',
'sp.paid': 'վճարված՝ {sum}',
'sp.fotTax': 'աշխ. {fot}, հարկ {tax}',
'sp.payoutTitle': 'Վճարում գործընկերոջը',
'sp.phone': 'Հեռախոս',
'sp.service': 'Ծառայություն',
'sp.cleaningOnly': 'Մաքրում (առայժմ միայն այն)',
'sp.deal': 'Համագործակցության մոդել',
'sp.deal.COMMISSION': 'Uber-ի պես՝ մեր %-ը',
'sp.deal.PROFIT_SHARE': 'Ծախսերը մերն են՝ % գործընկերոջը',
'sp.dealHint.COMMISSION': 'Վճարված պատվերից վերցնում ենք մեր %-ը, մնացածը՝ գործընկերոջը։ Աշխատավարձն ու ծախսերը նրանն են։',
'sp.dealHint.PROFIT_SHARE': 'Մաքրողի աշխատավարձը և հարկը վճարում ենք մենք․ գործընկերոջը՝ մնացորդի իր %-ը՝ վճարված − աշխ. − հարկ։',
'sp.commissionPct': 'Մեր %-ը՝ վճարված պատվերից',
'sp.profitSharePct': 'Գործընկերոջ %-ը շահույթից',
'sp.fotPct': 'Մաքրողի աշխ., պատվերի %',
'sp.taxPct': 'Հարկ, %',
'sp.defaultHint': 'դատարկ՝ կարգավորումներից ({n}%)',
'sp.exampleCOMMISSION': 'Օրինակ՝ վճարվել է {sum} → մեզ {ours}, գործընկերոջը {partner}։',
'sp.examplePROFIT_SHARE': 'Օրինակ՝ վճարված {sum} − աշխ. {fot} − հարկ {tax} = {profit}․ գործընկերոջը {partner}, մեզ {ours}։',
'sp.activeLabel': 'Գործընկերն աշխատում է (կարելի է նշանակել պատվերներ)',
'nav.points': 'Բարիկներ',
'st.group.points': 'Բարիկներ (քեշբեք)',
'ag.hwPoints': 'Բարիկներ բնակարանամուտին',
'ag.hwDays': 'Ժամկետ, օր',
'ag.hwHint': '0՝ չենք նվիրում։ Մեկ անգամ, անմիջապես հասանելի։',
'ag.hwValue': '{n} բարիկ {days} օրով',
'pt.title': 'Բարիկներ',
'pt.tab.accrue': 'Հաշվեգրել',
'pt.tab.requests': 'Հայտեր',
'pt.tab.suggestions': 'Համակարգի առաջարկներ',
'pt.tab.ledger': 'Մատյան',
'pt.tab.report': 'Հաշվետվություն',
'pt.tab.import': 'CSV ներմուծում',
'pt.tab.setup': 'Տոկոսներ և բոնուսներ',
'pt.search': 'Հաճախորդ',
'pt.searchPh': 'Հեռախոս, @username, անուն կամ ID',
'pt.notFound': 'Ոչ ոք չգտնվեց',
'pt.pickClient': 'Գտեք հաճախորդին ձախից․ այստեղ կհայտնվի ձևը։',
'pt.flagged': 'ստուգել՝ շատ չեղարկումներ',
'pt.balance': 'Հասանելի',
'pt.pending': 'Սպասման մեջ',
'pt.availableFrom': '{date}-ից',
'pt.tier': 'Մակարդակ',
'pt.burnAt': 'Կայրվեն',
'pt.agency': 'Գործակալություն',
'pt.streak': 'Մաքրումների շարք',
'pt.debt': 'Պարտք',
'pt.redeem': 'Դուրս գրել',
'pt.redeemBtn': 'Դուրս գրել',
'pt.redeemed': 'Դուրս է գրված',
'pt.allOps': 'Բոլոր գործողությունները',
'pt.noOps': 'Գործողություններ դեռ չկան։',
'pt.reverse': 'Չեղարկել',
'pt.accrueTitle': 'Հաշվեգրում',
'pt.kind.ORDER': 'Պատվերի համար',
'pt.kind.BONUS': 'Բոնուս',
'pt.f.category': 'Կատեգորիա',
'pt.f.orderAmount': 'Պատվերի գումար, ֏',
'pt.f.excluded': 'Չի հաշվարկվում, ֏',
'pt.f.excludedHint': 'տուրքեր, նյութեր, թեյավճար',
'pt.f.paidWithPoints': 'Վճարված բարիկներով',
'pt.f.serviceDate': 'Ծառայության ամսաթիվ',
'pt.f.orderRef': 'Պատվերի համար',
'pt.f.orderRefHint': 'նույն համարով երկրորդ անգամ չի կարելի',
'pt.f.bonusType': 'Բոնուսի տեսակ',
'pt.f.ttl': 'Ժամկետ, օր',
'pt.f.ttlDefault': 'լռելյայն՝ {n}',
'pt.f.ttlNone': 'դատարկ՝ ընդհանուր կանոն',
'pt.f.amount': 'Բարիկների գումար',
'pt.f.amountHint': 'առաջարկ՝ {n}',
'pt.f.reason': 'Պատճառ',
'pt.f.reasonRequired': 'Պատճառ (պարտադիր)',
'pt.f.comment': 'Մեկնաբանություն հաճախորդի համար',
'pt.f.commentPh': 'Կտեսնի պատմության մեջ',
'pt.f.noWait': 'Առանց սպասման՝ անմիջապես',
'pt.f.noWaitReason': 'Առանց սպասման (պատճառ պետք է)',
'pt.f.redeemAmount': 'Որքան դուրս գրել',
'pt.f.defaultAmount': 'Լռելյայն գումար',
'pt.formula': '{tier}՝ {rate}%{extra} → {n} բարիկ',
'pt.accrueBtn': 'Հաշվեգրել',
'pt.confirmTitle': 'Ստուգեք',
'pt.edit': 'Փոխել',
'pt.confirmOrder': 'Հաշվեգրել {n} բարիկ {name}-ին՝ {what} {sum} ֏ ({tier}, {rate}%)։ Հասանելի՝ {date}։',
'pt.confirmBonus': 'Հաշվեգրել {n} բարիկ {name}-ին՝ {what}, {until}։',
'pt.now': 'անմիջապես',
'pt.forDays': '{n} օրով',
'pt.noTtl': 'առանց ժամկետի',
'pt.changedNote': 'Գումարը տարբերվում է առաջարկից ({n})՝ կգրանցվի ձեր պատճառով։',
'pt.accrued': 'Հաշվեգրված է {n}',
'pt.needsApproval': 'Գերազանցում է սահմանաչափը՝ ուղարկված է սուպերադմինին',
'pt.zero': 'Գումարը 0 է',
'pt.reverseAccrual': 'Չեղարկել հաշվեգրումը',
'pt.reverseRedemption': 'Չեղարկել դուրսգրումը',
'pt.reverseAccrualText': '{n} բարիկի հաշվեգրումը կչեղարկվի։ Սկզբնական գրառումը կմնա մատյանում։',
'pt.reverseRedemptionText': '{n} բարիկը կվերադառնա սկզբնական հաշվեգրումներին։',
'pt.debtWarn': 'Արդեն ծախսված է {spent}՝ մնացորդը կդառնա բացասական՝ {after}։ Կծածկվի հաջորդ հաշվեգրումներով։',
'pt.reasonPh': 'Վերադարձ, սխալ…',
'pt.reverseHint': 'Աջակցությունը չեղարկում է միայն իր գործողությունները 24 ժամում։',
'pt.reversed': 'Չեղարկված է',
'pt.maxHint': 'առավելագույնը {max} ({share}%), նվազագույնը {min}',
'pt.redeemReasonPh': 'Զանգով, պատվեր հավելվածից դուրս…',
'pt.toPay': 'Մնաց վճարել՝ {sum} ֏',
'pt.noRequests': 'Հայտեր չկան։',
'pt.wants': 'ուզում է դուրս գրել {n}',
'pt.forOrder': 'պատվեր №{n}',
'pt.noOrder': 'պատվեր հավելվածից դուրս',
'pt.balanceN': 'մնացորդ {n}',
'pt.confirm': 'Հաստատել',
'pt.confirmN': 'Դուրս գրել {n}',
'pt.confirmed': 'Դուրս է գրված {n}',
'pt.reject': 'Մերժել',
'pt.rejectReason': 'Պատճառը՝ կտեսնի հաճախորդը',
'pt.noSuggestions': 'Առաջարկներ չկան։',
'pt.suggestionsHint': 'Համակարգը գտել է, ում է հասնում բոնուս։ Հաշվեգրեք, փոխեք գումարը կամ մերժեք։',
'pt.mech.first_month_route': 'Առաջին ամսվա երթուղի',
'pt.mech.photo_review': 'Կարծիք լուսանկարով',
'pt.mech.over_limit': 'Աջակցության սահմանաչափից բարձր',
'pt.why.first_month_route': 'առաջին 30 օրում 3 տարբեր կատեգորիա',
'pt.why.photo_review': 'լուսանկար է ուղարկել №{n}-ից հետո',
'pt.why.over_limit': '{by}՝ {op}',
'pt.op.accrual': 'հաշվեգրում',
'pt.op.redemption': 'դուրսգրում',
'pt.approve': 'Հաստատել',
'pt.changeAmount': 'Փոխել գումարը',
'pt.allTypes': 'Բոլոր տեսակները',
'pt.allCategories': 'Բոլոր կատեգորիաները',
'pt.allChannels': 'Բոլոր ալիքները',
'pt.allClients': 'Բոլոր հաճախորդները',
'pt.type.accrual': 'Հաշվեգրում',
'pt.type.bonus': 'Բոնուս',
'pt.type.redemption': 'Դուրսգրում',
'pt.type.request': 'Հաճախորդի հայտ',
'pt.type.reversal': 'Չեղարկում',
'pt.channel.WEB': 'ադմին',
'pt.channel.BOT': 'բոտ',
'pt.channel.CSV': 'CSV',
'pt.channel.AUTO': 'ավտո',
'pt.channel.CLIENT': 'հաճախորդ',
'pt.status.PENDING': 'սպասման մեջ',
'pt.status.ACTIVE': 'ակտիվ',
'pt.status.SPENT': 'ծախսված',
'pt.status.EXPIRED': 'այրված',
'pt.status.REVERSED': 'չեղարկված',
'pt.status.REQUESTED': 'սպասում է',
'pt.status.CONFIRMED': 'դուրս գրված',
'pt.status.REJECTED': 'մերժված',
'pt.status.ACCRUAL': 'հաշվեգրման',
'pt.status.REDEMPTION': 'դուրսգրման',
'pt.col.date': 'Երբ',
'pt.col.client': 'Հաճախորդ',
'pt.col.what': 'Ինչ',
'pt.col.amount': 'Բարիկներ',
'pt.col.status': 'Կարգավիճակ',
'pt.col.author': 'Ով',
'pt.system': 'համակարգ',
'pt.suggestedN': 'առաջարկ {n}',
'pt.debtN': 'պարտք {n}',
'pt.rep.accrued': 'Հաշվեգրված',
'pt.rep.spent': 'Ծախսված',
'pt.rep.expired': 'Այրված',
'pt.rep.reversed': 'Չեղարկված',
'pt.rep.liabilities': 'Պարտավորություններ',
'pt.rep.rate': 'Ծախսման տոկոս',
'pt.rep.byCategory': 'Ծրագրի արժեքը ըստ կատեգորիաների',
'pt.rep.cost': 'հասույթի {pct}%՝ {revenue} ֏',
'pt.rep.retention': 'Կրկնակի պատվեր 30 օրում',
'pt.rep.with': 'Բարիկներով',
'pt.rep.without': 'Առանց բարիկների',
'pt.rep.clientsN': '{n} հաճախորդ, 2+ կատեգորիայով՝ {multi}',
'pt.rep.topClients': 'Թոփ ըստ մնացորդի',
'pt.rep.topAgencies': 'Գործակալություններ՝ բնակարանամուտի ակտիվացումներ',
'pt.importHint': 'CSV՝ հեռախոս, կատեգորիա, պատվերի գումար, ամսաթիվ (ՏՏՏՏ-ԱԱ-ՕՕ), պատվերի համար։ Նախ՝ նախադիտում։',
'pt.importRows': 'Տողեր',
'pt.importTotal': 'Ընդամենը բարիկ',
'pt.importErr.client': 'հաճախորդը չգտնվեց',
'pt.importErr.category': 'անհայտ կատեգորիա',
'pt.importErr.date': 'սխալ ամսաթիվ',
'pt.importErr.duplicate': 'կրկնվող պատվեր',
'pt.importCommit': 'Հաշվեգրել {n} տող',
'pt.importDone': 'Հաշվեգրված է {n} տող',
'pt.categories': 'Կատեգորիաներ և տոկոսներ',
'pt.bonusTypes': 'Բոնուսների տեսակներ',
'pt.code': 'Կոդ',
'pt.activeLabel': 'Ակտիվ',
'pt.rulesHint': 'Հասանելի՝ {hours} ժ հետո · մինչև {share}%, նվազագույնը {min} · այրվում են {burn} օր անգործությունից հետո։',
'err.POINTS_DUPLICATE_ORDER': 'Այս պատվերի համար արդեն հաշվեգրվել է։',
'err.POINTS_REASON_REQUIRED': 'Նշեք պատճառը։',
'err.POINTS_BELOW_MIN': 'Նվազագույնից քիչ է։',
'err.POINTS_NOT_ENOUGH': 'Բարիկները չեն բավարարում։',
'err.POINTS_OVER_SHARE': 'Ավելի է թույլատրելի մասից։',
'err.POINTS_OVER_LIMIT': 'Սահմանաչափից բարձր է։',
'err.POINTS_FINAL': 'Այլևս հնարավոր չէ չեղարկել։',
'err.POINTS_REQUEST_CLOSED': 'Հայտն արդեն մշակված է։',
'err.POINTS_WITH_PROMO': 'Բարիկները չեն համատեղվում պրոմոկոդի հետ։',
'pt.formulaStreak': ' + շարք {streak}՝ +{bonus}%',
'ord.editComposition': 'Փոխել կազմը',
'ord.editTitle': '№{n} պատվերի կազմը',
'ord.editPrice': 'Գինը՝ {was} → {now} (զեղչ {pct}%)',
'ord.editDuration': 'Տևողություն՝ {was} → {now} րոպե',
'ord.editBusy': 'Պատվերը երկարեց, իսկ կատարողն այդ ժամին զբաղված է։',
'ord.editPaid': 'Արդեն վճարված է․ կվերահաշվարկվեն պարգևը, գործընկերոջ եկամուտը և բարիկները։',
'ord.editOwnPrice': 'Սեփական գին, ֏',
'ord.editOwnPriceHint': 'դատարկ՝ գնացուցակով՝ {n}',
'ord.editReason': 'Պատճառ',
'ord.editReasonRequired': 'Պատճառ (պարտադիր)',
'ord.editReasonPh': 'Տեղում ավելացրին պատուհաններ…',
'ord.editNotify': 'Տեղեկացնել հաճախորդին և կատարողին',
'ord.edited': 'Պատվերը փոխված է',
'ord.editedBusy': 'Փոխված է։ Ստուգեք կատարողի գրաֆիկը',
'ord.uncheck': 'Հանել նշումը',
'ord.checkForProvider': 'Նշել կատարողի փոխարեն',
'ord.noPhotoReason': 'առանց լուսանկարի՝ {reason}',
'ord.byStaff': 'նշել է թիմը',
'ord.checkNoPhoto': 'Նշել առանց լուսանկարի',
'ord.photoHint': 'Այս կետի համար պետք է լուսանկար։',
'ord.uploadPhoto': 'Վերբեռնել լուսանկար',
'ord.noPhotoField': 'Կամ պատճառը, թե ինչու առանց լուսանկարի',
'ord.noPhotoPh': 'Մաքրողն ուղարկել է զրույցում…',
'err.PHOTO_REQUIRED': 'Պետք է լուսանկար կամ պատճառ։',
};
const DICTS: Record<BoLocale, Dict> = { ru, en, hy };
+33
View File
@@ -379,8 +379,12 @@
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;
@@ -393,6 +397,15 @@
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;
@@ -698,3 +711,23 @@
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);
}
+5 -2
View File
@@ -261,8 +261,11 @@ export function Journal() {
const GROUPS: { key: string; keys: string[] }[] = [
{ key: 'orders', keys: ['cancel.freeHours', 'offer.acceptTimeoutMinutes', 'assign.auto', 'assign.maxAutoOffers'] },
{ key: 'subs', keys: ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'barevPlus.giftDays', 'elite.monthlyPrice', 'elite.maxDiscountPct'] },
{ key: 'agencies', keys: ['agency.attributionMonths', 'agency.defaultCommissionPct'] },
{ 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'] },
+44 -3
View File
@@ -26,6 +26,15 @@ function CopyLink({ url }: { url: string }) {
}
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();
@@ -64,7 +73,7 @@ export function Agencies() {
<Avatar name={a.name} id={a.id} size={38} />
<span style={{ minWidth: 0 }}>
<span className="bo-who__name">{a.name}</span>
<span className="bo-who__meta">{[a.code, `${a.commissionPct}%`, a.sales?.name].filter(Boolean).join(' · ')}</span>
<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>
@@ -125,6 +134,8 @@ export function AgencyCard({ id }: { id: string }) {
<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>}
@@ -227,7 +238,7 @@ export function AgencyCard({ id }: { id: string }) {
);
}
function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code: string; name: string; commissionPct: number; contact: string | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) {
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();
@@ -237,6 +248,10 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
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,
@@ -267,7 +282,7 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
type="button"
className="bo-btn"
disabled={!f.name.trim() || !f.code.trim() || save.isPending}
onClick={() => save.mutate({ id: agency?.id, code: f.code, name: f.name, commissionPct: Math.min(100, Number(f.commissionPct) || 0), contact: f.contact || null, note: f.note || null, active: f.active, salesId: f.salesId || null })}
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>
@@ -297,6 +312,32 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
</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>
+2
View File
@@ -1,3 +1,4 @@
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';
@@ -234,6 +235,7 @@ 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 }}>
+6 -3
View File
@@ -519,14 +519,17 @@ export function Requests({ me }: { me: Me }) {
<b>
<span className="bo-num bo-faint">№{r.number}</span> {shared(`services.${r.service.code}.name`)}
</b>
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
<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>{dateTime(r.preferredAt)}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
{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">
{shared(`districts.${r.district.code}`)}, {r.addressText}
{[r.district ? shared(`districts.${r.district.code}`) : t('rq.noDistrict'), r.addressText ?? t('rq.noAddress')].join(', ')}
</div>
<div className="bo-rq__who">
<span>
+2 -2
View File
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge } from '../components/kit.tsx';
type ParamField =
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 }
@@ -262,7 +262,7 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
);
}
function ParamsForm({ service, fields, value, onChange }: { service: string; fields: ParamField[]; value: Params; onChange: (v: Params) => void }) {
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`;
+170 -9
View File
@@ -5,7 +5,7 @@ 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 { SlotPicker } from './NewOrder.tsx';
import { ParamsForm, SlotPicker, type ParamField } from './NewOrder.tsx';
const MIN = 60_000;
@@ -15,7 +15,8 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
const qc = useQueryClient();
const toast = useToast();
const o = useQuery({ ...trpc.bo.orders.get.queryOptions({ id }), refetchInterval: 15_000 });
const [dlg, setDlg] = useState<'reschedule' | 'cancel' | null>(null);
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 }) });
@@ -26,6 +27,7 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
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');
@@ -92,6 +94,11 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
<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>
@@ -143,9 +150,19 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
<ul className="bo-checks">
{d.checklist.map((c) => (
<li key={c.code} data-done={c.done || undefined}>
<span className="bo-checks__box">{c.done && <I name="check" size={14} stroke={3} />}</span>
{shared(`checklist.${c.code}`)}
{c.requiresPhoto && !c.fileId && <span className="bo-faint bo-small"> · {t('ord.needsPhoto')}</span>}
<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>
@@ -332,6 +349,8 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
</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} />}
</>
);
@@ -379,7 +398,7 @@ function useOrderData(id: string) {
}
function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onChanged: () => void; canChat: boolean }) {
const { t, shared } = useT();
const { t, shared, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const [showBusy, setShowBusy] = useState(false);
@@ -464,15 +483,28 @@ function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onC
) : (
<ul className="bo-cands">
{cands.data.free.map((c, i) => (
<li key={`${c.kind}:${c.id}`}>
<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.company')}</span>}
{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}`)), t('ord.load', { n: c.load })].filter(Boolean).join(' · ')}
{[
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')}
@@ -610,3 +642,132 @@ function CancelDialog({ id, showFee, onClose, onDone }: { id: string; showFee: b
</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>
);
}
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -299,8 +299,10 @@ export function ProviderCard({ id, me }: { id: string; me: Me }) {
);
}
function rateText(t: ReturnType<typeof useT>['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number; name?: string }) {
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 });
}
@@ -0,0 +1,485 @@
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>
);
}