Backoffice stage 3: orders and providers, auto-assignment

- Auto-assignment: a new order is offered to the best free provider (same ranking as
  the dispatcher hint); a decline or timeout passes it to the next one; nobody is
  offered the same order twice; after N tries it waits for a dispatcher and the team
  is notified once. The worker retries waiting orders every minute. Settings
  assign.auto / assign.maxAutoOffers (toggle on the Orders page, checkbox in old admin).
- Orders: list by day with groups (need a provider, offered, assigned, in progress,
  done, cancelled), periods and search; order card with progress, provider or ranked
  candidates ("offer" / "pick automatically"), details, checklist and photos,
  history in plain words, client, money (superadmin), payment, status change,
  reschedule (slots or custom time), cancel with fee.
- New order for a client (from the client card or the Orders page): params form,
  address, free slots for 14 days, price with the client's discount.
- Providers: list with rating, orders, declines, low ratings, next order; card with
  quality, ratings, upcoming orders, schedule and districts, admission documents,
  block/unblock with reason, new bot link, money and payouts (superadmin).
- Payout gets paidAt/note/createdById; Provider gets blockedReason/blockedAt/adminNote
  and documents (ProviderDoc).
- Tests for auto-assignment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 02:25:41 +04:00
1 parent 2772837e29
commit e7de4f5893
33 files changed
+4521 -45

No files matched your search

+13 -2
View File
@@ -11,6 +11,10 @@ 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';
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
export interface Me {
@@ -44,8 +48,8 @@ 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', stage: 3, roles: ['SUPPORT'], count: 'orders' },
{ path: '/providers', key: 'nav.providers', icon: 'providers', stage: 3, roles: ['SUPPORT'], group: 'work' },
{ path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT'], count: 'orders' },
{ path: '/providers', key: 'nav.providers', icon: 'providers', roles: ['SUPPORT'], group: 'work' },
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', stage: 4, roles: [], group: 'partners' },
{ path: '/venues', key: 'nav.venues', icon: 'venues', stage: 4, roles: [], group: 'partners' },
{ path: '/crm', key: 'nav.crm', icon: 'crm', stage: 4, roles: ['SALES'], group: 'partners' },
@@ -74,8 +78,15 @@ function Shell({ me }: { me: Me }) {
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 = items.some((n) => n.path === '/orders');
if (path === '/') page = <Dashboard me={me} />;
else if ((path === '/chats' || threadId) && canChat) page = <Chats me={me} threadId={threadId} />;
else if (path === '/orders' && canOrders) page = <Orders me={me} />;
else if (orderId && canOrders) page = <OrderCard id={orderId} me={me} />;
else if (path === '/providers' && canOrders) page = <Providers me={me} />;
else if (providerId && canOrders) page = <ProviderCard id={providerId} me={me} />;
else if (path === '/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} />;
+2 -2
View File
@@ -95,7 +95,7 @@ export function sourceKeyLabel(t: T, key: string) {
}
/** Модальное окно на <dialog>: фокус, Esc и затемнение — силами браузера */
export function Dialog({ open, onClose, title, children, footer }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer: ReactNode }) {
export function Dialog({ open, onClose, title, children, footer, wide }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer: ReactNode; wide?: boolean }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
@@ -106,7 +106,7 @@ export function Dialog({ open, onClose, title, children, footer }: { open: boole
return (
<dialog
ref={ref}
className="bo-dialog"
className={`bo-dialog${wide ? ' bo-dialog--wide' : ''}`}
onClose={onClose}
onClick={(e) => {
if (e.target === ref.current) onClose();
+716 -2
View File
@@ -386,6 +386,242 @@ const ru = {
'chat.notLinked': 'Рабочий бот не открыт',
'chat.details': 'Подробнее',
'chat.more': 'Ещё действия',
// ───── заказы и исполнители (этап 3) ─────
'ord.title': 'Заказы',
'ord.new': 'Новый заказ',
'ord.search': '№, клиент, телефон, адрес или исполнитель',
'ord.period': 'Период',
'ord.period.all': 'Все даты',
'ord.period.today': 'Сегодня',
'ord.period.tomorrow': 'Завтра',
'ord.period.week': '7 дней',
'ord.period.past': 'Прошедшие',
'ord.group.active': 'В работе у команды',
'ord.group.new': 'Ждут исполнителя',
'ord.group.pending': 'Предложены',
'ord.group.assigned': 'Назначены',
'ord.group.in_progress': 'Выполняются',
'ord.group.done': 'Готово',
'ord.group.cancelled': 'Отменены',
'ord.today': 'Сегодня',
'ord.tomorrow': 'Завтра',
'ord.empty': 'Заказов с такими условиями нет.',
'ord.waitsAnswer': 'ждём ответа: {name}',
'ord.needsHuman': 'нужен диспетчер',
'ord.searching': 'система подбирает…',
'ord.overdue': 'время прошло, исполнителя нет',
'ord.needsDispatcher': 'Автораспределение не нашло исполнителя для заказов: {n}. Назначьте вручную.',
'ord.autoOn': 'Автораспределение включено',
'ord.autoOff': 'Автораспределение выключено',
'ord.autoHint': 'Новый заказ сам предлагается лучшему свободному исполнителю (язык клиента, район, загрузка, рейтинг). Нет ответа {minutes} мин или отказ — следующему; после {tries} попыток — диспетчеру.',
'ord.back': 'Все заказы',
'ord.progress': 'Ход заказа',
'ord.details': 'Детали',
'ord.when': 'Когда',
'ord.where': 'Где',
'ord.minutes': '{n} мин',
'ord.params': 'Параметры',
'ord.extras': 'Доп. услуги',
'ord.note': 'Комментарий клиента',
'ord.lang': 'Язык клиента',
'ord.created': 'Создан',
'ord.checklist': 'Чек-лист',
'ord.needsPhoto': 'нужно фото',
'ord.photo': 'Фото',
'ord.history': 'История',
'ord.client': 'Клиент',
'ord.writeClient': 'Написать клиенту',
'ord.writeProvider': 'Написать исполнителю',
'ord.money': 'Деньги',
'ord.base': 'Без скидки',
'ord.discount': 'Скидка {pct}%',
'ord.payout': 'Исполнителю',
'ord.cancelFee': 'Удержание за отмену',
'ord.payment': 'Оплата',
'ord.markPaid': 'Отметить оплату',
'ord.confirmPaid': 'Отметить, что клиент оплатил заказ?',
'ord.paidDone': 'Оплата отмечена',
'ord.actions': 'Действия',
'ord.setStatus': 'Статус: {status}',
'ord.confirmStatus': 'Перевести заказ в «{status}»? Клиент получит уведомление.',
'ord.statusChanged': 'Статус изменён',
'ord.reschedule': 'Перенести',
'ord.rescheduleTitle': 'Перенос заказа №{n}',
'ord.rescheduleHint': 'Если исполнитель занят в новое время, заказ вернётся в «Ждут исполнителя» и уйдёт в автораспределение.',
'ord.rescheduled': 'Заказ перенесён, исполнитель тот же',
'ord.rescheduledFree': 'Заказ перенесён; исполнитель занят — ищем нового',
'ord.comment': 'Комментарий',
'ord.commentPh': 'Например: клиент попросил по телефону',
'ord.cancel': 'Отменить заказ',
'ord.cancelTitle': 'Отмена заказа',
'ord.cancelReason': 'Причина отмены',
'ord.cancelReasonPh': 'Например: клиент передумал',
'ord.cancelFeeHint': 'Сколько удержать с клиента, ֏. Обычно 0.',
'ord.cancelHint': 'Клиент и исполнитель получат уведомление.',
'ord.cancelled': 'Заказ отменён',
'ord.rating': 'Оценка клиента',
'ord.noComment': 'Без комментария',
'ord.offers': 'Кому предлагали',
'ord.bySystem': 'система',
'ord.provider': 'Исполнитель',
'ord.unassign': 'Снять исполнителя',
'ord.revoke': 'Отозвать предложение',
'ord.confirmUnassign': 'Снять исполнителя? Заказ вернётся в «Ждут исполнителя».',
'ord.confirmRevoke': 'Отозвать предложение? Заказ снова будет ждать исполнителя.',
'ord.unassigned': 'Готово — заказ снова ждёт исполнителя',
'ord.pendingProvider': 'Предложен: {name}',
'ord.pendingCompany': 'Передан компании «{name}» — ждём, кого назначит диспетчер',
'ord.byAuto': 'предложила система',
'ord.minLeft': 'на ответ ≈ {n} мин',
'ord.pickHint': 'Свободные на это время — по порядку подбора. Или поручите выбор системе.',
'ord.autoPick': 'Подобрать автоматически',
'ord.autoOffered': 'Предложили: {name}',
'ord.autoNone': 'Свободных исполнителей не нашлось',
'ord.noFree': 'На это время все заняты. Перенесите заказ или договоритесь с исполнителем.',
'ord.busy': 'Заняты в это время: {n}',
'ord.company': 'компания',
'ord.tried': 'уже предлагали',
'ord.load': 'заказов в этот день: {n}',
'ord.offer': 'Предложить',
'ord.offered': 'Заказ отправлен исполнителю',
'ord.noProvider': 'Исполнитель не назначался.',
'ord.ev.created': 'Заказ создан',
'ord.ev.createdByStaff': 'Заказ оформила команда',
'ord.ev.offered': 'Предложен исполнителю',
'ord.ev.autoOffered': 'Автораспределение: предложен',
'ord.ev.autoNoCandidate': 'Автораспределение: свободных нет',
'ord.ev.autoGaveUp': 'Автораспределение остановлено — нужен диспетчер',
'no.title': 'Новый заказ',
'no.client': 'Клиент',
'no.clientPh': 'Имя, @username или телефон',
'no.noClients': 'Никого не нашли',
'no.changeClient': 'Другой клиент',
'no.service': 'Услуга',
'no.extras': 'Доп. услуги',
'no.address': 'Адрес',
'no.addressPh': 'Улица, дом, квартира',
'no.district': 'Район',
'no.when': 'Дата и время',
'no.day': 'День',
'no.noSlots': 'На этот день свободного времени нет — выберите другой день или «другое время».',
'no.otherTime': 'Другое время (договорились с исполнителем сами)',
'no.customTime': 'Время',
'no.customHint': 'По Еревану. Свободных исполнителей система не гарантирует.',
'no.note': 'Комментарий для исполнителя',
'no.notePh': 'Код домофона, животные…',
'no.payment': 'Оплата',
'no.hint': 'Цена — с учётом подписки клиента. Клиент получит уведомление в бота, заказ сразу уйдёт в автораспределение.',
'no.create': 'Оформить заказ',
'no.created': 'Заказ №{n} оформлен',
'prv.title': 'Исполнители',
'prv.add': 'Добавить',
'prv.search': 'Имя или телефон',
'prv.status.active': 'Работают',
'prv.status.blocked': 'Заблокированы',
'prv.status.all': 'Все',
'prv.empty': 'Никого не нашли.',
'prv.col.name': 'Исполнитель',
'prv.col.rating': 'Рейтинг',
'prv.col.done': 'Заказов',
'prv.col.refused': 'Отказы',
'prv.col.next': 'Ближайший заказ',
'prv.period30': 'Заказы, отказы и низкие оценки — за 30 дней.',
'prv.blocked': 'заблокирован',
'prv.onShift': 'на смене',
'prv.offShift': 'не на смене',
'prv.notLinked': 'не открыл бота',
'prv.noDocs': 'нет документов',
'prv.ratings': 'оценок: {n}',
'prv.low': 'низких: {n}',
'prv.today': 'сегодня: {n}',
'prv.back': 'Все исполнители',
'prv.write': 'Написать',
'prv.edit': 'Редактировать',
'prv.blockedSince': 'Заблокирован с {date}.',
'prv.unblock': 'Вернуть к работе',
'prv.inviteHint': 'Исполнитель ещё не открыл рабочий бот — отправьте ему ссылку.',
'prv.copyInvite': 'Скопировать ссылку',
'prv.quality': 'Качество',
'prv.days30': 'за 30 дней',
'prv.acceptRate': 'принял {a} из {n} предложений',
'prv.refusals': 'отказы: {d}, молчал: {e}',
'prv.lowRatings': 'оценок 3 и ниже',
'prv.upcoming': 'Ближайшие заказы',
'prv.noUpcoming': 'Назначенных заказов нет.',
'prv.recent': 'Последние выполненные',
'prv.balance': 'К выплате',
'prv.thisMonth': 'Этот месяц',
'prv.prevMonth': 'Прошлый',
'prv.paidTotal': 'Выплачено',
'prv.payout': 'Отметить выплату',
'prv.payoutTitle': 'Выплата исполнителю',
'prv.amount': 'Сумма, ֏',
'prv.balanceHint': 'Долг перед исполнителем: {sum}',
'prv.payoutNote': 'Как выплатили',
'prv.payoutNotePh': 'Наличные / перевод на карту…',
'prv.payoutConfirm': 'Выплачено',
'prv.payoutDone': 'Выплата отмечена',
'prv.rateCompany': 'ставка компании {v}',
'prv.rateOwn': 'ставка {v}',
'prv.ratePreset': 'по пресету «{name}»',
'prv.work': 'Работа',
'prv.languages': 'Языки',
'prv.districts': 'Районы',
'prv.anyDistrict': 'любые',
'prv.districtsHint': 'Подбор ставит выше тех, кто работает в районе заказа.',
'prv.docs': 'Документы для допуска',
'prv.noDocsHint': 'Паспорт, договор, справка — всё хранится в закрытом хранилище.',
'prv.docTitlePh': 'Название: паспорт, договор…',
'prv.upload': 'Загрузить',
'prv.docAdded': 'Документ добавлен',
'prv.removeDocConfirm': 'Удалить документ?',
'prv.access': 'Допуск',
'prv.block': 'Заблокировать',
'prv.blockTitle': 'Заблокировать исполнителя',
'prv.blockReason': 'Причина',
'prv.blockReasonPh': 'Например: жалобы клиентов, нет документов',
'prv.blockHint': 'Заказы ему больше не предлагаются, действующие предложения отзываются. Уже назначенные заказы остаются — переназначьте их вручную.',
'prv.resetInvite': 'Новая ссылка для входа в бот',
'prv.resetConfirm': 'Сделать новую ссылку? Текущая привязка к Telegram снимется — исполнителю нужно будет войти заново.',
'prv.since': 'С нами с {date}',
'prv.saved': 'Сохранено',
'prv.addTitle': 'Новый исполнитель',
'prv.editTitle': 'Профиль исполнителя',
'prv.name': 'Имя',
'prv.role': 'Роль',
'prv.company': 'Компания',
'prv.freelance': 'Частный исполнитель',
'prv.schedule': 'График',
'prv.rate': 'Ставка',
'prv.note': 'Заметка команды',
'err.REASON_REQUIRED': 'Укажите причину.',
'err.SLOT_UNAVAILABLE': 'Это время уже занято или прошло. Выберите другое или «другое время».',
'err.ALREADY_ASSIGNED': 'Заказ уже назначен или предложен — обновите страницу.',
'err.PROVIDER_BUSY': 'Исполнитель занят в это время.',
'err.TRANSITION_NOT_ALLOWED': 'Сейчас этот шаг для заказа недоступен.',
'err.CHECKLIST_INCOMPLETE': 'Сначала исполнитель должен закрыть чек-лист и приложить фото.',
'err.CONSENT_REQUIRED': 'Клиент ещё не дал согласие на обработку данных в боте — заказ оформить нельзя.',
'err.ADDRESS_REQUIRED': 'Укажите адрес и район.',
'err.INVALID_PARAMS': 'Проверьте параметры заказа.',
'err.OFFER_NOT_ACTIVE': 'Предложение уже неактуально.',
'err.BAD_FILE_TYPE_DOC': 'Можно фото или PDF.',
'audit.bo.orders.assign': 'предложил(а) заказ исполнителю',
'audit.bo.orders.auto': 'запустил(а) подбор исполнителя',
'audit.bo.orders.unassign': 'снял(а) исполнителя с заказа',
'audit.bo.orders.transition': 'сменил(а) статус заказа',
'audit.bo.orders.cancel': 'отменил(а) заказ',
'audit.bo.orders.reschedule': 'перенёс(ла) заказ',
'audit.bo.orders.markPaid': 'отметил(а) оплату заказа',
'audit.bo.orders.create': 'оформил(а) заказ за клиента',
'audit.bo.orders.setAutoSetting': 'изменил(а) автораспределение',
'audit.bo.providers.create': 'добавил(а) исполнителя',
'audit.bo.providers.update': 'изменил(а) профиль исполнителя',
'audit.bo.providers.setActive': 'изменил(а) допуск исполнителя',
'audit.bo.providers.resetInvite': 'выпустил(а) новую ссылку исполнителю',
'audit.bo.providers.removeDoc': 'удалил(а) документ исполнителя',
'audit.bo.providers.addDoc': 'загрузил(а) документ исполнителя',
'audit.bo.providers.payout': 'отметил(а) выплату исполнителю',
'chat.order': 'Заказ №{n}',
'chat.noOrder': 'Без заказа',
'chat.noOrderNow': 'Сейчас без заказа',
@@ -792,6 +1028,242 @@ const en: Dict = {
'chat.notLinked': 'Work bot not opened',
'chat.details': 'Details',
'chat.more': 'More actions',
// ───── заказы и исполнители (этап 3) ─────
'ord.title': 'Orders',
'ord.new': 'New order',
'ord.search': 'No., client, phone, address or provider',
'ord.period': 'Period',
'ord.period.all': 'All dates',
'ord.period.today': 'Today',
'ord.period.tomorrow': 'Tomorrow',
'ord.period.week': '7 days',
'ord.period.past': 'Past',
'ord.group.active': 'Active',
'ord.group.new': 'Need a provider',
'ord.group.pending': 'Offered',
'ord.group.assigned': 'Assigned',
'ord.group.in_progress': 'In progress',
'ord.group.done': 'Done',
'ord.group.cancelled': 'Cancelled',
'ord.today': 'Today',
'ord.tomorrow': 'Tomorrow',
'ord.empty': 'No orders match these filters.',
'ord.waitsAnswer': 'awaiting: {name}',
'ord.needsHuman': 'needs a dispatcher',
'ord.searching': 'auto-assigning…',
'ord.overdue': 'time passed, no provider',
'ord.needsDispatcher': 'Auto-assignment found no provider for {n} order(s). Assign manually.',
'ord.autoOn': 'Auto-assign on',
'ord.autoOff': 'Auto-assign off',
'ord.autoHint': 'A new order is offered to the best free provider (client language, district, load, rating). No answer in {minutes} min or a decline — the next one; after {tries} tries — to a dispatcher.',
'ord.back': 'All orders',
'ord.progress': 'Progress',
'ord.details': 'Details',
'ord.when': 'When',
'ord.where': 'Where',
'ord.minutes': '{n} min',
'ord.params': 'Details',
'ord.extras': 'Extras',
'ord.note': 'Client comment',
'ord.lang': 'Client language',
'ord.created': 'Created',
'ord.checklist': 'Checklist',
'ord.needsPhoto': 'photo needed',
'ord.photo': 'Photo',
'ord.history': 'History',
'ord.client': 'Client',
'ord.writeClient': 'Message the client',
'ord.writeProvider': 'Message the provider',
'ord.money': 'Money',
'ord.base': 'Before discount',
'ord.discount': 'Discount {pct}%',
'ord.payout': 'To the provider',
'ord.cancelFee': 'Cancellation fee',
'ord.payment': 'Payment',
'ord.markPaid': 'Mark as paid',
'ord.confirmPaid': 'Mark the order as paid?',
'ord.paidDone': 'Marked as paid',
'ord.actions': 'Actions',
'ord.setStatus': 'Status: {status}',
'ord.confirmStatus': 'Move the order to “{status}”? The client will be notified.',
'ord.statusChanged': 'Status changed',
'ord.reschedule': 'Reschedule',
'ord.rescheduleTitle': 'Reschedule order #{n}',
'ord.rescheduleHint': 'If the provider is busy at the new time, the order goes back to “Need a provider” and to auto-assignment.',
'ord.rescheduled': 'Rescheduled, same provider',
'ord.rescheduledFree': 'Rescheduled; the provider is busy — looking for a new one',
'ord.comment': 'Comment',
'ord.commentPh': 'E.g. the client asked by phone',
'ord.cancel': 'Cancel order',
'ord.cancelTitle': 'Cancel the order',
'ord.cancelReason': 'Reason',
'ord.cancelReasonPh': 'E.g. the client changed their mind',
'ord.cancelFeeHint': 'How much to charge the client, ֏. Usually 0.',
'ord.cancelHint': 'The client and the provider will be notified.',
'ord.cancelled': 'Order cancelled',
'ord.rating': 'Client rating',
'ord.noComment': 'No comment',
'ord.offers': 'Offers',
'ord.bySystem': 'system',
'ord.provider': 'Provider',
'ord.unassign': 'Remove provider',
'ord.revoke': 'Withdraw offer',
'ord.confirmUnassign': 'Remove the provider? The order goes back to “Need a provider”.',
'ord.confirmRevoke': 'Withdraw the offer? The order will wait for a provider again.',
'ord.unassigned': 'Done — the order needs a provider again',
'ord.pendingProvider': 'Offered to {name}',
'ord.pendingCompany': 'Passed to {name} — waiting for their dispatcher',
'ord.byAuto': 'offered automatically',
'ord.minLeft': '≈ {n} min to answer',
'ord.pickHint': 'Free at this time, best match first. Or let the system pick.',
'ord.autoPick': 'Pick automatically',
'ord.autoOffered': 'Offered to {name}',
'ord.autoNone': 'No free provider found',
'ord.noFree': 'Everyone is busy at this time. Reschedule or arrange with a provider.',
'ord.busy': 'Busy at this time: {n}',
'ord.company': 'company',
'ord.tried': 'already offered',
'ord.load': '{n} orders that day',
'ord.offer': 'Offer',
'ord.offered': 'Offer sent',
'ord.noProvider': 'No provider was assigned.',
'ord.ev.created': 'Order created',
'ord.ev.createdByStaff': 'Created by the team',
'ord.ev.offered': 'Offered to a provider',
'ord.ev.autoOffered': 'Auto-assign: offered',
'ord.ev.autoNoCandidate': 'Auto-assign: nobody free',
'ord.ev.autoGaveUp': 'Auto-assign stopped — needs a dispatcher',
'no.title': 'New order',
'no.client': 'Client',
'no.clientPh': 'Name, @username or phone',
'no.noClients': 'Nobody found',
'no.changeClient': 'Another client',
'no.service': 'Service',
'no.extras': 'Extras',
'no.address': 'Address',
'no.addressPh': 'Street, building, apartment',
'no.district': 'District',
'no.when': 'Date and time',
'no.day': 'Day',
'no.noSlots': 'No free time that day — pick another day or “other time”.',
'no.otherTime': 'Other time (arranged with a provider directly)',
'no.customTime': 'Time',
'no.customHint': 'Yerevan time. A free provider is not guaranteed.',
'no.note': 'Comment for the provider',
'no.notePh': 'Door code, pets…',
'no.payment': 'Payment',
'no.hint': 'The price includes the client’s subscription discount. The client is notified and the order goes to auto-assignment.',
'no.create': 'Create order',
'no.created': 'Order #{n} created',
'prv.title': 'Providers',
'prv.add': 'Add',
'prv.search': 'Name or phone',
'prv.status.active': 'Active',
'prv.status.blocked': 'Blocked',
'prv.status.all': 'All',
'prv.empty': 'Nobody found.',
'prv.col.name': 'Provider',
'prv.col.rating': 'Rating',
'prv.col.done': 'Orders',
'prv.col.refused': 'Declines',
'prv.col.next': 'Next order',
'prv.period30': 'Orders, declines and low ratings — last 30 days.',
'prv.blocked': 'blocked',
'prv.onShift': 'on shift',
'prv.offShift': 'off shift',
'prv.notLinked': 'bot not opened',
'prv.noDocs': 'no documents',
'prv.ratings': '{n} ratings',
'prv.low': '{n} low',
'prv.today': 'today: {n}',
'prv.back': 'All providers',
'prv.write': 'Message',
'prv.edit': 'Edit',
'prv.blockedSince': 'Blocked since {date}.',
'prv.unblock': 'Unblock',
'prv.inviteHint': 'The provider hasn’t opened the work bot yet — send them the link.',
'prv.copyInvite': 'Copy link',
'prv.quality': 'Quality',
'prv.days30': 'last 30 days',
'prv.acceptRate': 'accepted {a} of {n} offers',
'prv.refusals': 'declined: {d}, no answer: {e}',
'prv.lowRatings': 'ratings of 3 or less',
'prv.upcoming': 'Upcoming orders',
'prv.noUpcoming': 'No assigned orders.',
'prv.recent': 'Recently done',
'prv.balance': 'To pay',
'prv.thisMonth': 'This month',
'prv.prevMonth': 'Last month',
'prv.paidTotal': 'Paid',
'prv.payout': 'Record a payout',
'prv.payoutTitle': 'Payout to provider',
'prv.amount': 'Amount, ֏',
'prv.balanceHint': 'Owed to the provider: {sum}',
'prv.payoutNote': 'How it was paid',
'prv.payoutNotePh': 'Cash / card transfer…',
'prv.payoutConfirm': 'Paid',
'prv.payoutDone': 'Payout recorded',
'prv.rateCompany': 'company rate {v}',
'prv.rateOwn': 'rate {v}',
'prv.ratePreset': 'preset “{name}”',
'prv.work': 'Work',
'prv.languages': 'Languages',
'prv.districts': 'Districts',
'prv.anyDistrict': 'any',
'prv.districtsHint': 'Assignment favors providers working in the order’s district.',
'prv.docs': 'Admission documents',
'prv.noDocsHint': 'Passport, contract, certificate — kept in private storage.',
'prv.docTitlePh': 'Title: passport, contract…',
'prv.upload': 'Upload',
'prv.docAdded': 'Document added',
'prv.removeDocConfirm': 'Delete the document?',
'prv.access': 'Access',
'prv.block': 'Block',
'prv.blockTitle': 'Block the provider',
'prv.blockReason': 'Reason',
'prv.blockReasonPh': 'E.g. client complaints, missing documents',
'prv.blockHint': 'No new offers; pending offers are withdrawn. Already assigned orders stay — reassign them manually.',
'prv.resetInvite': 'New bot link',
'prv.resetConfirm': 'Create a new link? The current Telegram link is removed — the provider will need to log in again.',
'prv.since': 'With us since {date}',
'prv.saved': 'Saved',
'prv.addTitle': 'New provider',
'prv.editTitle': 'Provider profile',
'prv.name': 'Name',
'prv.role': 'Role',
'prv.company': 'Company',
'prv.freelance': 'Freelancer',
'prv.schedule': 'Schedule',
'prv.rate': 'Rate',
'prv.note': 'Team note',
'err.REASON_REQUIRED': 'Please give a reason.',
'err.SLOT_UNAVAILABLE': 'This time is taken or has passed. Pick another one.',
'err.ALREADY_ASSIGNED': 'The order is already assigned or offered — refresh the page.',
'err.PROVIDER_BUSY': 'The provider is busy at this time.',
'err.TRANSITION_NOT_ALLOWED': 'This step isn’t available for the order now.',
'err.CHECKLIST_INCOMPLETE': 'The provider must finish the checklist and add photos first.',
'err.CONSENT_REQUIRED': 'The client hasn’t given data-processing consent in the bot yet.',
'err.ADDRESS_REQUIRED': 'Please enter the address and district.',
'err.INVALID_PARAMS': 'Check the order details.',
'err.OFFER_NOT_ACTIVE': 'The offer is no longer active.',
'err.BAD_FILE_TYPE_DOC': 'Photo or PDF only.',
'audit.bo.orders.assign': 'offered an order',
'audit.bo.orders.auto': 'ran auto-assignment',
'audit.bo.orders.unassign': 'removed a provider from an order',
'audit.bo.orders.transition': 'changed an order status',
'audit.bo.orders.cancel': 'cancelled an order',
'audit.bo.orders.reschedule': 'rescheduled an order',
'audit.bo.orders.markPaid': 'marked an order as paid',
'audit.bo.orders.create': 'created an order for a client',
'audit.bo.orders.setAutoSetting': 'changed auto-assignment',
'audit.bo.providers.create': 'added a provider',
'audit.bo.providers.update': 'edited a provider',
'audit.bo.providers.setActive': 'changed provider access',
'audit.bo.providers.resetInvite': 'reissued a provider link',
'audit.bo.providers.removeDoc': 'deleted a provider document',
'audit.bo.providers.addDoc': 'uploaded a provider document',
'audit.bo.providers.payout': 'recorded a payout',
'chat.order': 'Order #{n}',
'chat.noOrder': 'No order',
'chat.noOrderNow': 'No current order',
@@ -1195,6 +1667,242 @@ const hy: Dict = {
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
'chat.details': 'Մանրամասներ',
'chat.more': 'Այլ գործողություններ',
// ───── заказы и исполнители (этап 3) ─────
'ord.title': 'Պատվերներ',
'ord.new': 'Նոր պատվեր',
'ord.search': '№, հաճախորդ, հեռախոս, հասցե կամ կատարող',
'ord.period': 'Ժամանակահատված',
'ord.period.all': 'Բոլոր օրերը',
'ord.period.today': 'Այսօր',
'ord.period.tomorrow': 'Վաղը',
'ord.period.week': '7 օր',
'ord.period.past': 'Անցածները',
'ord.group.active': 'Ակտիվ',
'ord.group.new': 'Սպասում են կատարողի',
'ord.group.pending': 'Առաջարկված',
'ord.group.assigned': 'Նշանակված',
'ord.group.in_progress': 'Կատարվում են',
'ord.group.done': 'Պատրաստ',
'ord.group.cancelled': 'Չեղարկված',
'ord.today': 'Այսօր',
'ord.tomorrow': 'Վաղը',
'ord.empty': 'Այս պայմաններով պատվերներ չկան։',
'ord.waitsAnswer': 'սպասում ենք՝ {name}',
'ord.needsHuman': 'պետք է դիսպետչեր',
'ord.searching': 'համակարգը ընտրում է…',
'ord.overdue': 'ժամն անցել է, կատարող չկա',
'ord.needsDispatcher': 'Ավտոմատ բաշխումը կատարող չգտավ {n} պատվերի համար։ Նշանակեք ձեռքով։',
'ord.autoOn': 'Ավտոմատ բաշխումը միացված է',
'ord.autoOff': 'Ավտոմատ բաշխումն անջատված է',
'ord.autoHint': 'Նոր պատվերն ինքնաբերաբար առաջարկվում է լավագույն ազատ կատարողին։ {minutes} րոպեում պատասխան չկա կամ հրաժարվեց՝ հաջորդին, {tries} փորձից հետո՝ դիսպետչերին։',
'ord.back': 'Բոլոր պատվերները',
'ord.progress': 'Ընթացք',
'ord.details': 'Մանրամասներ',
'ord.when': 'Երբ',
'ord.where': 'Որտեղ',
'ord.minutes': '{n} ր',
'ord.params': 'Պարամետրեր',
'ord.extras': 'Լրացուցիչ',
'ord.note': 'Հաճախորդի մեկնաբանություն',
'ord.lang': 'Հաճախորդի լեզուն',
'ord.created': 'Ստեղծված',
'ord.checklist': 'Ստուգացանկ',
'ord.needsPhoto': 'լուսանկար է պետք',
'ord.photo': 'Լուսանկար',
'ord.history': 'Պատմություն',
'ord.client': 'Հաճախորդ',
'ord.writeClient': 'Գրել հաճախորդին',
'ord.writeProvider': 'Գրել կատարողին',
'ord.money': 'Գումար',
'ord.base': 'Առանց զեղչի',
'ord.discount': 'Զեղչ {pct}%',
'ord.payout': 'Կատարողին',
'ord.cancelFee': 'Չեղարկման պահում',
'ord.payment': 'Վճարում',
'ord.markPaid': 'Նշել վճարումը',
'ord.confirmPaid': 'Նշե՞լ, որ հաճախորդը վճարել է։',
'ord.paidDone': 'Վճարումը նշված է',
'ord.actions': 'Գործողություններ',
'ord.setStatus': 'Կարգավիճակ՝ {status}',
'ord.confirmStatus': 'Տեղափոխե՞լ «{status}»։ Հաճախորդը ծանուցում կստանա։',
'ord.statusChanged': 'Կարգավիճակը փոխված է',
'ord.reschedule': 'Տեղափոխել',
'ord.rescheduleTitle': 'Պատվեր №{n}-ի տեղափոխում',
'ord.rescheduleHint': 'Եթե կատարողը զբաղված է նոր ժամին, պատվերը կվերադառնա «Սպասում են կատարողի»։',
'ord.rescheduled': 'Տեղափոխված է, կատարողը նույնն է',
'ord.rescheduledFree': 'Տեղափոխված է, կատարողը զբաղված է՝ փնտրում ենք նորը',
'ord.comment': 'Մեկնաբանություն',
'ord.commentPh': 'Օրինակ՝ հաճախորդը խնդրեց հեռախոսով',
'ord.cancel': 'Չեղարկել պատվերը',
'ord.cancelTitle': 'Պատվերի չեղարկում',
'ord.cancelReason': 'Չեղարկման պատճառը',
'ord.cancelReasonPh': 'Օրինակ՝ հաճախորդը փոխեց միտքը',
'ord.cancelFeeHint': 'Որքան պահել հաճախորդից, ֏։ Սովորաբար 0։',
'ord.cancelHint': 'Հաճախորդը և կատարողը ծանուցում կստանան։',
'ord.cancelled': 'Պատվերը չեղարկված է',
'ord.rating': 'Հաճախորդի գնահատականը',
'ord.noComment': 'Առանց մեկնաբանության',
'ord.offers': 'Ում է առաջարկվել',
'ord.bySystem': 'համակարգ',
'ord.provider': 'Կատարող',
'ord.unassign': 'Հանել կատարողին',
'ord.revoke': 'Հետ կանչել առաջարկը',
'ord.confirmUnassign': 'Հանե՞լ կատարողին։ Պատվերը կվերադառնա։',
'ord.confirmRevoke': 'Հետ կանչե՞լ առաջարկը։',
'ord.unassigned': 'Պատվերը կրկին սպասում է կատարողի',
'ord.pendingProvider': 'Առաջարկված է՝ {name}',
'ord.pendingCompany': 'Հանձնված է «{name}» ընկերությանը',
'ord.byAuto': 'առաջարկել է համակարգը',
'ord.minLeft': 'պատասխանի համար ≈ {n} ր',
'ord.pickHint': 'Այս ժամին ազատներ՝ ըստ համապատասխանության։',
'ord.autoPick': 'Ընտրել ավտոմատ',
'ord.autoOffered': 'Առաջարկվեց՝ {name}',
'ord.autoNone': 'Ազատ կատարող չգտնվեց',
'ord.noFree': 'Այս ժամին բոլորը զբաղված են։',
'ord.busy': 'Զբաղված են՝ {n}',
'ord.company': 'ընկերություն',
'ord.tried': 'արդեն առաջարկվել է',
'ord.load': 'այդ օրը պատվերներ՝ {n}',
'ord.offer': 'Առաջարկել',
'ord.offered': 'Առաջարկն ուղարկված է',
'ord.noProvider': 'Կատարող չի նշանակվել։',
'ord.ev.created': 'Պատվերը ստեղծված է',
'ord.ev.createdByStaff': 'Ստեղծել է թիմը',
'ord.ev.offered': 'Առաջարկվել է կատարողին',
'ord.ev.autoOffered': 'Ավտոմատ բաշխում՝ առաջարկված',
'ord.ev.autoNoCandidate': 'Ավտոմատ բաշխում՝ ազատ չկա',
'ord.ev.autoGaveUp': 'Ավտոմատ բաշխումը կանգնեց',
'no.title': 'Նոր պատվեր',
'no.client': 'Հաճախորդ',
'no.clientPh': 'Անուն, @username կամ հեռախոս',
'no.noClients': 'Ոչ ոք չգտնվեց',
'no.changeClient': 'Այլ հաճախորդ',
'no.service': 'Ծառայություն',
'no.extras': 'Լրացուցիչ',
'no.address': 'Հասցե',
'no.addressPh': 'Փողոց, շենք, բնակարան',
'no.district': 'Վարչական շրջան',
'no.when': 'Օր և ժամ',
'no.day': 'Օր',
'no.noSlots': 'Այս օրը ազատ ժամ չկա։',
'no.otherTime': 'Այլ ժամ (պայմանավորվել ենք կատարողի հետ)',
'no.customTime': 'Ժամ',
'no.customHint': 'Երևանի ժամով։',
'no.note': 'Մեկնաբանություն կատարողի համար',
'no.notePh': 'Դոմոֆոնի կոդ, կենդանիներ…',
'no.payment': 'Վճարում',
'no.hint': 'Գինը՝ հաշվի առնելով բաժանորդագրությունը։ Հաճախորդը ծանուցում կստանա։',
'no.create': 'Ձևակերպել պատվերը',
'no.created': 'Պատվեր №{n}-ը ձևակերպված է',
'prv.title': 'Կատարողներ',
'prv.add': 'Ավելացնել',
'prv.search': 'Անուն կամ հեռախոս',
'prv.status.active': 'Աշխատում են',
'prv.status.blocked': 'Արգելափակված',
'prv.status.all': 'Բոլորը',
'prv.empty': 'Ոչ ոք չգտնվեց։',
'prv.col.name': 'Կատարող',
'prv.col.rating': 'Վարկանիշ',
'prv.col.done': 'Պատվերներ',
'prv.col.refused': 'Հրաժարումներ',
'prv.col.next': 'Մոտակա պատվերը',
'prv.period30': 'Պատվերներ, հրաժարումներ, ցածր գնահատականներ՝ 30 օրում։',
'prv.blocked': 'արգելափակված',
'prv.onShift': 'հերթափոխում',
'prv.offShift': 'հերթափոխում չէ',
'prv.notLinked': 'բոտը բացված չէ',
'prv.noDocs': 'փաստաթղթեր չկան',
'prv.ratings': 'գնահատականներ՝ {n}',
'prv.low': 'ցածր՝ {n}',
'prv.today': 'այսօր՝ {n}',
'prv.back': 'Բոլոր կատարողները',
'prv.write': 'Գրել',
'prv.edit': 'Խմբագրել',
'prv.blockedSince': 'Արգելափակված է {date}-ից։',
'prv.unblock': 'Վերադարձնել աշխատանքի',
'prv.inviteHint': 'Կատարողը դեռ չի բացել աշխատանքային բոտը՝ ուղարկեք հղումը։',
'prv.copyInvite': 'Պատճենել հղումը',
'prv.quality': 'Որակ',
'prv.days30': '30 օրում',
'prv.acceptRate': 'ընդունել է {a}-ը {n}-ից',
'prv.refusals': 'հրաժարվել՝ {d}, լռել՝ {e}',
'prv.lowRatings': 'գնահատական 3 և ցածր',
'prv.upcoming': 'Մոտակա պատվերներ',
'prv.noUpcoming': 'Նշանակված պատվերներ չկան։',
'prv.recent': 'Վերջին կատարվածները',
'prv.balance': 'Վճարման ենթակա',
'prv.thisMonth': 'Այս ամիս',
'prv.prevMonth': 'Անցյալ',
'prv.paidTotal': 'Վճարված',
'prv.payout': 'Նշել վճարումը',
'prv.payoutTitle': 'Վճարում կատարողին',
'prv.amount': 'Գումար, ֏',
'prv.balanceHint': 'Պարտք կատարողին՝ {sum}',
'prv.payoutNote': 'Ինչպես է վճարվել',
'prv.payoutNotePh': 'Կանխիկ / փոխանցում…',
'prv.payoutConfirm': 'Վճարված է',
'prv.payoutDone': 'Վճարումը նշված է',
'prv.rateCompany': 'ընկերության դրույք {v}',
'prv.rateOwn': 'դրույք {v}',
'prv.ratePreset': '«{name}» նախադրույքով',
'prv.work': 'Աշխատանք',
'prv.languages': 'Լեզուներ',
'prv.districts': 'Շրջաններ',
'prv.anyDistrict': 'ցանկացած',
'prv.districtsHint': 'Ընտրությունը նախապատվություն է տալիս շրջանում աշխատողներին։',
'prv.docs': 'Թույլտվության փաստաթղթեր',
'prv.noDocsHint': 'Անձնագիր, պայմանագիր, տեղեկանք՝ փակ պահոցում։',
'prv.docTitlePh': 'Անվանում՝ անձնագիր, պայմանագիր…',
'prv.upload': 'Վերբեռնել',
'prv.docAdded': 'Փաստաթուղթն ավելացված է',
'prv.removeDocConfirm': 'Ջնջե՞լ փաստաթուղթը։',
'prv.access': 'Թույլտվություն',
'prv.block': 'Արգելափակել',
'prv.blockTitle': 'Արգելափակել կատարողին',
'prv.blockReason': 'Պատճառը',
'prv.blockReasonPh': 'Օրինակ՝ բողոքներ, փաստաթղթեր չկան',
'prv.blockHint': 'Նոր առաջարկներ չեն լինի։ Նշանակված պատվերները վերանշանակեք ձեռքով։',
'prv.resetInvite': 'Նոր հղում բոտի համար',
'prv.resetConfirm': 'Ստեղծե՞լ նոր հղում։ Ընթացիկ կապը կհանվի։',
'prv.since': 'Մեզ հետ է {date}-ից',
'prv.saved': 'Պահպանված է',
'prv.addTitle': 'Նոր կատարող',
'prv.editTitle': 'Կատարողի պրոֆիլ',
'prv.name': 'Անուն',
'prv.role': 'Դեր',
'prv.company': 'Ընկերություն',
'prv.freelance': 'Անհատ կատարող',
'prv.schedule': 'Ժամանակացույց',
'prv.rate': 'Դրույք',
'prv.note': 'Թիմի նշում',
'err.REASON_REQUIRED': 'Նշեք պատճառը։',
'err.SLOT_UNAVAILABLE': 'Այս ժամը զբաղված է կամ անցել է։',
'err.ALREADY_ASSIGNED': 'Պատվերն արդեն նշանակված է։',
'err.PROVIDER_BUSY': 'Կատարողը զբաղված է այդ ժամին։',
'err.TRANSITION_NOT_ALLOWED': 'Այս քայլը հիմա հասանելի չէ։',
'err.CHECKLIST_INCOMPLETE': 'Կատարողը նախ պետք է ավարտի ստուգացանկը։',
'err.CONSENT_REQUIRED': 'Հաճախորդը դեռ համաձայնություն չի տվել։',
'err.ADDRESS_REQUIRED': 'Նշեք հասցեն և շրջանը։',
'err.INVALID_PARAMS': 'Ստուգեք պատվերի պարամետրերը։',
'err.OFFER_NOT_ACTIVE': 'Առաջարկն այլևս ակտիվ չէ։',
'err.BAD_FILE_TYPE_DOC': 'Միայն լուսանկար կամ PDF։',
'audit.bo.orders.assign': 'առաջարկեց պատվերը',
'audit.bo.orders.auto': 'գործարկեց ավտոմատ ընտրությունը',
'audit.bo.orders.unassign': 'հանեց կատարողին',
'audit.bo.orders.transition': 'փոխեց պատվերի կարգավիճակը',
'audit.bo.orders.cancel': 'չեղարկեց պատվերը',
'audit.bo.orders.reschedule': 'տեղափոխեց պատվերը',
'audit.bo.orders.markPaid': 'նշեց վճարումը',
'audit.bo.orders.create': 'ձևակերպեց պատվեր',
'audit.bo.orders.setAutoSetting': 'փոխեց ավտոմատ բաշխումը',
'audit.bo.providers.create': 'ավելացրեց կատարող',
'audit.bo.providers.update': 'փոխեց կատարողի պրոֆիլը',
'audit.bo.providers.setActive': 'փոխեց կատարողի թույլտվությունը',
'audit.bo.providers.resetInvite': 'նոր հղում տվեց կատարողին',
'audit.bo.providers.removeDoc': 'ջնջեց կատարողի փաստաթուղթը',
'audit.bo.providers.addDoc': 'վերբեռնեց կատարողի փաստաթուղթը',
'audit.bo.providers.payout': 'նշեց վճարում կատարողին',
'chat.order': 'Պատվեր №{n}',
'chat.noOrder': 'Առանց պատվերի',
'chat.noOrderNow': 'Այժմ պատվեր չկա',
@@ -1235,8 +1943,12 @@ interface Ctx {
locale: BoLocale;
setLocale: (l: BoLocale) => void;
t: T;
/** общие переводы проекта: services.<code>.name и т.п. */
shared: (key: string) => string;
/** общие переводы проекта: services.<code>.name, roles.<ROLE>, params.* и т.п. */
shared: (key: string, params?: Record<string, string | number>) => string;
/** время по Еревану: 14:00 */
time: (d: Date) => string;
/** общий t проекта целиком — для хелперов @barev/web (paramsSummary и т.п.) */
sharedT: ReturnType<typeof createSharedT>;
amd: (n: number) => string;
date: (d: Date) => string;
dateTime: (d: Date) => string;
@@ -1279,10 +1991,12 @@ export function BoI18nProvider({ children }: { children: ReactNode }) {
},
t: makeT(locale),
shared: sharedT,
sharedT,
amd: (n) => formatAmd(locale as SharedLocale, n),
amdShort: (n) => `${compact.format(n)} ֏`,
date: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', year: 'numeric' }),
dateTime: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
time: (d) => formatDateTime(locale as SharedLocale, d, { hour: '2-digit', minute: '2-digit' }),
};
}, [locale]);
return <I18nCtx.Provider value={value}>{children}</I18nCtx.Provider>;
+1
View File
@@ -1,6 +1,7 @@
import '@barev/ui/styles.css';
import './styles.css';
import './chats.css';
import './orders.css';
import { ApiProvider, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+694
View File
@@ -0,0 +1,694 @@
/*
* Этап 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-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;
gap: 6px;
}
.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__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;
}
}
+2 -2
View File
@@ -528,7 +528,7 @@ function OrderTag({ order }: { order: OrderBrief | null }) {
</div>
);
return (
<a className="bo-ordertag" href={`#/clients/${order.clientId}`} title={t('chat.orderClient', { name: order.clientName })}>
<a className="bo-ordertag" href={`#/orders/${order.id}`} title={t('chat.orderClient', { name: order.clientName })}>
<I name="orders" size={13} />
{text(order)}
</a>
@@ -545,7 +545,7 @@ function ProviderOrders({ current, next }: { current: OrderBrief | null; next: O
<span className="bo-grow" style={{ minWidth: 0 }}>
{current ? (
<>
<b>{t('chat.orderNow')}:</b> {text(current, true)}
<b>{t('chat.orderNow')}:</b> <a href={`#/orders/${current.id}`} style={{ color: 'inherit' }}>{text(current, true)}</a>
</>
) : (
<b>{t('chat.noOrderNow')}</b>
+11 -6
View File
@@ -1,6 +1,7 @@
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 { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT, type BoKey } from '../i18n.tsx';
@@ -16,6 +17,7 @@ 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;
@@ -189,10 +191,12 @@ 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>
<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>
{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>
)}
</div>
{d.orders.length === 0 ? (
<p className="bo-hint">{t('card.noOrders')}</p>
@@ -200,9 +204,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' }}>
<span className="bo-num bo-faint" style={{ width: 52 }}>
<a href={`#/orders/${o.id}`} className="bo-num bo-faint" style={{ width: 52 }}>
№{o.number}
</span>
</a>
<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' }}>
@@ -324,6 +328,7 @@ 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)} />}
</>
);
}
+2 -2
View File
@@ -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', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'unassigned', href: '#/orders?group=new', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'unreadChats', href: '#/chats', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ 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: 'lowRatings', href: '#/providers', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
{ key: 'expiring', href: '#/clients?expiring=7', fill: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' },
];
+391
View File
@@ -0,0 +1,391 @@
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';
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 ready = !!service && Object.keys(params).length > 0;
const quote = useQuery({ ...trpc.bo.orders.quote.queryOptions({ clientId, service: service?.code ?? '', params }), enabled: ready && !!clientId, placeholderData: keepPreviousData });
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,
})
}
>
{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>
<p className="bo-hint">{t('no.hint')}</p>
</Dialog>
);
}
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>
);
}
+585
View File
@@ -0,0 +1,585 @@
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 { SlotPicker } 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' | 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 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>
</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}>
<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>}
</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 === '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 } = 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>
{canChat && (
<button type="button" className="bo-iconbtn" title={t('ord.writeProvider')} aria-label={t('ord.writeProvider')} onClick={() => chat.mutate({ providerId: d.provider!.id })}>
<I name="chats" size={20} />
</button>
)}
</div>
) : 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.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}`}>
<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.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(' · ')}
</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 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>
);
}
+231
View File
@@ -0,0 +1,231 @@
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';
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>
{!!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>
);
}
+659
View File
@@ -0,0 +1,659 @@
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; name?: string }) {
if (r.kind === 'preset') return t('prv.ratePreset', { name: r.name ?? '' });
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
@@ -0,0 +1,144 @@
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)} />}
</>
);
}