Relocations and requests in the new admin; star rating in the client bot
Orders section gets tabs: Orders / Relocations / Requests (concierge sees the last two). Relocation list with statuses, plan progress and current step; relocation card with per-client steps (status, client comment, rename, add, remove), documents, quiz answers, team note, write/call. Requests: website cleaning requests and app requests (membership, call back) with notes, create order, turn on membership. Dashboard links point here instead of the old admin. Fix: leads with an unanswered 'already moved?' (NULL) were skipped by 'moved not true' filters — plan sync and linking to the app client. Client bot: after an order is done, asks for 1-5 stars; low rating asks what went wrong, the reply is saved as the rating comment and goes to the team chat. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
bbb25d3d0d
commit
dcba7c200c
19 files changed
+1422
-22
No files matched your search
@@ -21,6 +21,7 @@ import { AgencyCard, Agencies } from './pages/Agencies.tsx';
|
||||
import { VenueCard, Venues } from './pages/Venues.tsx';
|
||||
import { Products, ServiceEditor } from './pages/Products.tsx';
|
||||
import { Broadcasts, Journal, Settings } from './pages/Admin5.tsx';
|
||||
import { MoveCard, Moves, Requests } from './pages/Moves.tsx';
|
||||
|
||||
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
|
||||
export interface Me {
|
||||
@@ -54,7 +55,7 @@ const NAV: NavItem[] = [
|
||||
{ path: '/', key: 'nav.dashboard', icon: 'dashboard', roles: 'all' },
|
||||
{ path: '/clients', key: 'nav.clients', icon: 'clients', roles: ['SUPPORT', 'CONCIERGE'] },
|
||||
{ path: '/chats', key: 'nav.chats', icon: 'chats', roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
|
||||
{ path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT'], count: 'orders' },
|
||||
{ path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT', 'CONCIERGE'], count: 'orders' },
|
||||
{ path: '/providers', key: 'nav.providers', icon: 'providers', roles: ['SUPPORT'], group: 'work' },
|
||||
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', roles: [], group: 'partners' },
|
||||
{ path: '/venues', key: 'nav.venues', icon: 'venues', roles: [], group: 'partners' },
|
||||
@@ -87,7 +88,10 @@ function Shell({ me }: { me: Me }) {
|
||||
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');
|
||||
// заказы и исполнители — поддержка; переезды и заявки — ещё и консьерж
|
||||
const canOrders = isSuper || me.roles.includes('SUPPORT');
|
||||
const canMoves = items.some((n) => n.path === '/orders');
|
||||
const moveId = match('/moves/:id', path)?.id;
|
||||
const leadId = match('/crm/:id', path)?.id;
|
||||
const agencyId = match('/agencies/:id', path)?.id;
|
||||
const venueId = match('/venues/:id', path)?.id;
|
||||
@@ -98,6 +102,9 @@ function Shell({ me }: { me: Me }) {
|
||||
if (path === '/') page = <Dashboard me={me} />;
|
||||
else if ((path === '/chats' || threadId) && canChat) page = <Chats me={me} threadId={threadId} />;
|
||||
else if (path === '/orders' && canOrders) page = <Orders me={me} />;
|
||||
else if ((path === '/orders' || path === '/moves') && canMoves) page = <Moves me={me} />;
|
||||
else if (moveId && canMoves) page = <MoveCard id={moveId} me={me} />;
|
||||
else if (path === '/requests' && canMoves) page = <Requests me={me} />;
|
||||
else if (orderId && canOrders) page = <OrderCard id={orderId} me={me} />;
|
||||
else if (path === '/providers' && canOrders) page = <Providers me={me} />;
|
||||
else if (providerId && canOrders) page = <ProviderCard id={providerId} me={me} />;
|
||||
@@ -118,7 +125,8 @@ function Shell({ me }: { me: Me }) {
|
||||
else if (current?.stage) page = <Soon title={t(current.key)} stage={current.stage} />;
|
||||
else page = <Dashboard me={me} />;
|
||||
|
||||
const isActive = (n: NavItem) => (n.path === '/' ? path === '/' : path === n.path || path.startsWith(`${n.path}/`));
|
||||
const isActive = (n: NavItem) =>
|
||||
n.path === '/' ? path === '/' : path === n.path || path.startsWith(`${n.path}/`) || (n.path === '/orders' && (path.startsWith('/moves') || path === '/requests'));
|
||||
const tabItems = items.slice(0, 4);
|
||||
|
||||
return (
|
||||
|
||||
@@ -386,6 +386,78 @@ const ru = {
|
||||
'chat.notLinked': 'Рабочий бот не открыт',
|
||||
'chat.details': 'Подробнее',
|
||||
'chat.more': 'Ещё действия',
|
||||
'ord.tab.orders': 'Заказы',
|
||||
'ord.tab.moves': 'Переезды',
|
||||
'ord.tab.requests': 'Заявки',
|
||||
'mv.search': 'Имя, контакт, агентство или №',
|
||||
'mv.empty': 'Переездов с таким статусом нет.',
|
||||
'mv.all': 'Все',
|
||||
'mv.steps': 'Шаги {done} из {total}',
|
||||
'mv.docs': 'Документы {done}/{total}',
|
||||
'mv.now': 'Сейчас: {step}',
|
||||
'mv.noPlan': 'План ещё не собран',
|
||||
'mv.back': 'Все переезды',
|
||||
'mv.plan': 'План переезда',
|
||||
'mv.planHintApp': 'Клиент видит шаги в приложении. Когда меняете статус шага, ему приходит сообщение в бот.',
|
||||
'mv.planHintNoApp': 'Клиент ещё не открыл приложение — план пока видите только вы.',
|
||||
'mv.docsTitle': 'Документы клиента',
|
||||
'mv.docsHint': 'Клиент отмечает их в приложении, вы — здесь.',
|
||||
'mv.answers': 'Ответы в квизе',
|
||||
'mv.when': 'Когда переезд',
|
||||
'mv.package': 'Пакет',
|
||||
'mv.wants': 'Что нужно',
|
||||
'mv.agency': 'Агентство',
|
||||
'mv.lang': 'Язык',
|
||||
'mv.created': 'Заявка',
|
||||
'mv.unfinished': 'не дошёл до конца квиза',
|
||||
'mv.note': 'Заметка команды',
|
||||
'mv.notePh': 'Для себя: созвонились, что обещали, кто ведёт…',
|
||||
'mv.save': 'Сохранить',
|
||||
'mv.status': 'Статус переезда',
|
||||
'mv.contact': 'Контакт',
|
||||
'mv.write': 'Написать',
|
||||
'mv.call': 'Позвонить',
|
||||
'mv.openClient': 'Карточка клиента',
|
||||
'mv.inApp': 'в приложении',
|
||||
'mv.add': 'Добавить',
|
||||
'mv.addStep': 'Шаг из списка…',
|
||||
'mv.addDoc': 'Документ из списка…',
|
||||
'mv.custom': 'Свой пункт…',
|
||||
'mv.customPh': 'Название для этого клиента',
|
||||
'mv.stepNote': 'Комментарий для клиента — он увидит его в приложении',
|
||||
'mv.remove': 'Убрать у клиента',
|
||||
'mv.removeConfirm': 'Убрать «{name}» у этого клиента?',
|
||||
'mv.rename': 'Переименовать',
|
||||
'mv.listHint': 'Общий список шагов и документов настраивается в «Продуктах».',
|
||||
'rq.site': 'С сайта',
|
||||
'rq.app': 'Из приложения',
|
||||
'rq.open': 'Открытые',
|
||||
'rq.closed': 'Закрытые',
|
||||
'rq.siteHint': 'Уборка, заказанная на barevclub.com без регистрации. Позвоните, подтвердите время и создайте заказ.',
|
||||
'rq.appHint': 'Клиенты попросили подписку или звонок по услуге без онлайн-записи.',
|
||||
'rq.empty': 'Открытых заявок нет.',
|
||||
'rq.emptyClosed': 'Закрытых заявок пока нет.',
|
||||
'rq.callback': 'Перезвонить: {service}',
|
||||
'rq.newOrder': 'Создать заказ',
|
||||
'rq.wish': 'Желаемое время',
|
||||
'rq.price': 'Цена на сайте',
|
||||
'rq.noApp': 'не открыл бота',
|
||||
'rq.done': 'Связались',
|
||||
'rq.cancel': 'Отменить',
|
||||
'rq.reopen': 'Вернуть',
|
||||
'rq.activate': 'Включить на 30 дней',
|
||||
'rq.activated': 'Подписка включена',
|
||||
'rq.order': 'Заказ №{n}',
|
||||
'audit.bo.moves.update': 'изменил(а) переезд',
|
||||
'audit.bo.moves.updateStep': 'изменил(а) шаг переезда',
|
||||
'audit.bo.moves.addStep': 'добавил(а) шаг переезда',
|
||||
'audit.bo.moves.removeStep': 'убрал(а) шаг переезда',
|
||||
'audit.bo.moves.addDoc': 'добавил(а) документ переезда',
|
||||
'audit.bo.moves.updateDoc': 'изменил(а) документ переезда',
|
||||
'audit.bo.moves.removeDoc': 'убрал(а) документ переезда',
|
||||
'audit.bo.requests.updateSite': 'изменил(а) заявку с сайта',
|
||||
'audit.bo.requests.updateApp': 'изменил(а) заявку из приложения',
|
||||
'audit.bo.requests.activate': 'включил(а) подписку по заявке',
|
||||
'pr.newService': 'Новая услуга',
|
||||
'pr.newServiceHint': 'С нуля — с фиксированной ценой и вашими вопросами клиенту, или копией готовой услуги.',
|
||||
'pr.fromScratch': 'С нуля',
|
||||
@@ -1482,6 +1554,78 @@ const en: Dict = {
|
||||
'chat.notLinked': 'Work bot not opened',
|
||||
'chat.details': 'Details',
|
||||
'chat.more': 'More actions',
|
||||
'ord.tab.orders': 'Orders',
|
||||
'ord.tab.moves': 'Relocations',
|
||||
'ord.tab.requests': 'Requests',
|
||||
'mv.search': 'Name, contact, agency or #',
|
||||
'mv.empty': 'No relocations with this status.',
|
||||
'mv.all': 'All',
|
||||
'mv.steps': 'Steps {done} of {total}',
|
||||
'mv.docs': 'Documents {done}/{total}',
|
||||
'mv.now': 'Now: {step}',
|
||||
'mv.noPlan': 'No plan yet',
|
||||
'mv.back': 'All relocations',
|
||||
'mv.plan': 'Relocation plan',
|
||||
'mv.planHintApp': 'The client sees the steps in the app and gets a bot message when a step status changes.',
|
||||
'mv.planHintNoApp': 'The client hasn’t opened the app yet — only you see the plan.',
|
||||
'mv.docsTitle': 'Client documents',
|
||||
'mv.docsHint': 'The client ticks them in the app, you — here.',
|
||||
'mv.answers': 'Quiz answers',
|
||||
'mv.when': 'Moving date',
|
||||
'mv.package': 'Package',
|
||||
'mv.wants': 'Needs',
|
||||
'mv.agency': 'Agency',
|
||||
'mv.lang': 'Language',
|
||||
'mv.created': 'Request',
|
||||
'mv.unfinished': 'didn’t finish the quiz',
|
||||
'mv.note': 'Team note',
|
||||
'mv.notePh': 'For the team: called, what was promised, who leads…',
|
||||
'mv.save': 'Save',
|
||||
'mv.status': 'Relocation status',
|
||||
'mv.contact': 'Contact',
|
||||
'mv.write': 'Message',
|
||||
'mv.call': 'Call',
|
||||
'mv.openClient': 'Client card',
|
||||
'mv.inApp': 'in the app',
|
||||
'mv.add': 'Add',
|
||||
'mv.addStep': 'Step from the list…',
|
||||
'mv.addDoc': 'Document from the list…',
|
||||
'mv.custom': 'Custom item…',
|
||||
'mv.customPh': 'Name for this client',
|
||||
'mv.stepNote': 'Comment for the client — shown in the app',
|
||||
'mv.remove': 'Remove for this client',
|
||||
'mv.removeConfirm': 'Remove “{name}” for this client?',
|
||||
'mv.rename': 'Rename',
|
||||
'mv.listHint': 'The shared list of steps and documents is set up in Products.',
|
||||
'rq.site': 'From the website',
|
||||
'rq.app': 'From the app',
|
||||
'rq.open': 'Open',
|
||||
'rq.closed': 'Closed',
|
||||
'rq.siteHint': 'Cleaning booked on barevclub.com without sign-up. Call, confirm the time and create an order.',
|
||||
'rq.appHint': 'Clients asked for a membership or a call about a service without online booking.',
|
||||
'rq.empty': 'No open requests.',
|
||||
'rq.emptyClosed': 'No closed requests yet.',
|
||||
'rq.callback': 'Call back: {service}',
|
||||
'rq.newOrder': 'Create order',
|
||||
'rq.wish': 'Preferred time',
|
||||
'rq.price': 'Website price',
|
||||
'rq.noApp': 'hasn’t opened the bot',
|
||||
'rq.done': 'Contacted',
|
||||
'rq.cancel': 'Cancel',
|
||||
'rq.reopen': 'Reopen',
|
||||
'rq.activate': 'Turn on for 30 days',
|
||||
'rq.activated': 'Membership turned on',
|
||||
'rq.order': 'Order #{n}',
|
||||
'audit.bo.moves.update': 'updated a relocation',
|
||||
'audit.bo.moves.updateStep': 'updated a relocation step',
|
||||
'audit.bo.moves.addStep': 'added a relocation step',
|
||||
'audit.bo.moves.removeStep': 'removed a relocation step',
|
||||
'audit.bo.moves.addDoc': 'added a relocation document',
|
||||
'audit.bo.moves.updateDoc': 'updated a relocation document',
|
||||
'audit.bo.moves.removeDoc': 'removed a relocation document',
|
||||
'audit.bo.requests.updateSite': 'updated a website request',
|
||||
'audit.bo.requests.updateApp': 'updated an app request',
|
||||
'audit.bo.requests.activate': 'turned on a membership from a request',
|
||||
'pr.newService': 'New service',
|
||||
'pr.newServiceHint': 'From scratch with a fixed price and your own questions, or as a copy of an existing service.',
|
||||
'pr.fromScratch': 'From scratch',
|
||||
@@ -2575,6 +2719,78 @@ const hy: Dict = {
|
||||
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
|
||||
'chat.details': 'Մանրամասներ',
|
||||
'chat.more': 'Այլ գործողություններ',
|
||||
'ord.tab.orders': 'Պատվերներ',
|
||||
'ord.tab.moves': 'Տեղափոխություններ',
|
||||
'ord.tab.requests': 'Հայտեր',
|
||||
'mv.search': 'Անուն, կոնտակտ, գործակալություն կամ №',
|
||||
'mv.empty': 'Այս կարգավիճակով տեղափոխություններ չկան։',
|
||||
'mv.all': 'Բոլորը',
|
||||
'mv.steps': 'Քայլեր՝ {done}/{total}',
|
||||
'mv.docs': 'Փաստաթղթեր {done}/{total}',
|
||||
'mv.now': 'Հիմա՝ {step}',
|
||||
'mv.noPlan': 'Պլանը դեռ չկա',
|
||||
'mv.back': 'Բոլոր տեղափոխությունները',
|
||||
'mv.plan': 'Տեղափոխության պլան',
|
||||
'mv.planHintApp': 'Հաճախորդը տեսնում է քայլերը հավելվածում և ստանում է հաղորդագրություն։',
|
||||
'mv.planHintNoApp': 'Հաճախորդը դեռ չի բացել հավելվածը։',
|
||||
'mv.docsTitle': 'Հաճախորդի փաստաթղթեր',
|
||||
'mv.docsHint': 'Հաճախորդը նշում է հավելվածում, դուք՝ այստեղ։',
|
||||
'mv.answers': 'Պատասխաններ',
|
||||
'mv.when': 'Երբ',
|
||||
'mv.package': 'Փաթեթ',
|
||||
'mv.wants': 'Ինչ է պետք',
|
||||
'mv.agency': 'Գործակալություն',
|
||||
'mv.lang': 'Լեզու',
|
||||
'mv.created': 'Հայտ',
|
||||
'mv.unfinished': 'չի ավարտել',
|
||||
'mv.note': 'Թիմի նշում',
|
||||
'mv.notePh': 'Թիմի համար…',
|
||||
'mv.save': 'Պահպանել',
|
||||
'mv.status': 'Կարգավիճակ',
|
||||
'mv.contact': 'Կոնտակտ',
|
||||
'mv.write': 'Գրել',
|
||||
'mv.call': 'Զանգել',
|
||||
'mv.openClient': 'Հաճախորդի քարտ',
|
||||
'mv.inApp': 'հավելվածում',
|
||||
'mv.add': 'Ավելացնել',
|
||||
'mv.addStep': 'Քայլ ցուցակից…',
|
||||
'mv.addDoc': 'Փաստաթուղթ ցուցակից…',
|
||||
'mv.custom': 'Սեփական…',
|
||||
'mv.customPh': 'Անուն այս հաճախորդի համար',
|
||||
'mv.stepNote': 'Մեկնաբանություն հաճախորդի համար',
|
||||
'mv.remove': 'Հեռացնել',
|
||||
'mv.removeConfirm': 'Հեռացնե՞լ «{name}»։',
|
||||
'mv.rename': 'Վերանվանել',
|
||||
'mv.listHint': 'Ընդհանուր ցուցակը՝ «Ապրանքներ»-ում։',
|
||||
'rq.site': 'Կայքից',
|
||||
'rq.app': 'Հավելվածից',
|
||||
'rq.open': 'Բաց',
|
||||
'rq.closed': 'Փակ',
|
||||
'rq.siteHint': 'Մաքրում՝ պատվիրված կայքում։ Զանգեք և ստեղծեք պատվեր։',
|
||||
'rq.appHint': 'Հաճախորդները խնդրել են բաժանորդագրություն կամ զանգ։',
|
||||
'rq.empty': 'Բաց հայտեր չկան։',
|
||||
'rq.emptyClosed': 'Փակ հայտեր չկան։',
|
||||
'rq.callback': 'Հետ զանգել՝ {service}',
|
||||
'rq.newOrder': 'Ստեղծել պատվեր',
|
||||
'rq.wish': 'Ցանկալի ժամ',
|
||||
'rq.price': 'Գինը կայքում',
|
||||
'rq.noApp': 'բոտը չի բացել',
|
||||
'rq.done': 'Կապ հաստատվեց',
|
||||
'rq.cancel': 'Չեղարկել',
|
||||
'rq.reopen': 'Վերաբացել',
|
||||
'rq.activate': 'Միացնել 30 օրով',
|
||||
'rq.activated': 'Բաժանորդագրությունը միացված է',
|
||||
'rq.order': 'Պատվեր №{n}',
|
||||
'audit.bo.moves.update': 'փոխեց տեղափոխությունը',
|
||||
'audit.bo.moves.updateStep': 'փոխեց քայլը',
|
||||
'audit.bo.moves.addStep': 'ավելացրեց քայլ',
|
||||
'audit.bo.moves.removeStep': 'հեռացրեց քայլը',
|
||||
'audit.bo.moves.addDoc': 'ավելացրեց փաստաթուղթ',
|
||||
'audit.bo.moves.updateDoc': 'փոխեց փաստաթուղթը',
|
||||
'audit.bo.moves.removeDoc': 'հեռացրեց փաստաթուղթը',
|
||||
'audit.bo.requests.updateSite': 'փոխեց կայքի հայտը',
|
||||
'audit.bo.requests.updateApp': 'փոխեց հավելվածի հայտը',
|
||||
'audit.bo.requests.activate': 'միացրեց բաժանորդագրություն',
|
||||
'pr.newService': 'Նոր ծառայություն',
|
||||
'pr.newServiceHint': 'Զրոյից՝ ֆիքսված գնով, կամ պատրաստի ծառայության պատճենով։',
|
||||
'pr.fromScratch': 'Զրոյից',
|
||||
|
||||
@@ -4,6 +4,7 @@ import './chats.css';
|
||||
import './orders.css';
|
||||
import './crm.css';
|
||||
import './admin5.css';
|
||||
import './moves.css';
|
||||
import { ApiProvider, loadTextOverrides, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/* Раздел «Заказы»: вкладки Заказы · Переезды · Заявки */
|
||||
|
||||
.bo-subtabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: -4px 0 16px;
|
||||
border-bottom: 1px solid var(--bc-line);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.bo-subtabs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px 12px;
|
||||
margin-bottom: -1px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--bc-ink-2);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bo-subtabs a[aria-current='page'] {
|
||||
color: var(--bc-ink);
|
||||
border-bottom-color: var(--bc-plum);
|
||||
}
|
||||
.bo-subtabs__n {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--bc-accent);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/* ───── список переездов ───── */
|
||||
.bo-mvrows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.bo-mvrow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 0.8fr) minmax(0, 1.3fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--bo-r-row);
|
||||
background: var(--bo-panel);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.bo-mvrow:hover {
|
||||
box-shadow: 0 6px 20px rgba(30, 27, 26, 0.06);
|
||||
}
|
||||
.bo-mvrow__main,
|
||||
.bo-mvrow__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.bo-mvrow__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bo-mvrow__sub {
|
||||
font-size: 13px;
|
||||
color: var(--bc-ink-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bo-mvrow__status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
}
|
||||
.bo-mvbar {
|
||||
display: block;
|
||||
height: 5px;
|
||||
border-radius: 5px;
|
||||
background: var(--bc-line);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bo-mvbar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--bc-home);
|
||||
}
|
||||
.bo-mvbar--big {
|
||||
height: 8px;
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
|
||||
/* ───── карточка: шаги плана ───── */
|
||||
.bo-mvsteps {
|
||||
list-style: none;
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
}
|
||||
.bo-mvstep {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.bo-mvstep::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 24px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--bc-line);
|
||||
}
|
||||
.bo-mvstep:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
.bo-mvstep[data-status='DONE']::before {
|
||||
background: var(--bc-home);
|
||||
}
|
||||
.bo-mvstep__dot {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 7px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bc-line);
|
||||
background: var(--bo-panel);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--bc-home-ink);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.bo-mvstep[data-status='DONE'] .bo-mvstep__dot {
|
||||
background: var(--bc-home);
|
||||
border-color: var(--bc-home);
|
||||
}
|
||||
.bo-mvstep[data-status='IN_PROGRESS'] .bo-mvstep__dot {
|
||||
border: 6px solid var(--bc-accent);
|
||||
}
|
||||
.bo-mvstep[data-status='SKIPPED'] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.bo-mvstep__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.bo-mvstep__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.bo-mvstep__top .bo-input {
|
||||
flex: 1;
|
||||
}
|
||||
.bo-mvstep__top .bo-select {
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
.bo-mvstep__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 6px 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
cursor: text;
|
||||
}
|
||||
.bo-mvstep__name:hover {
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
.bo-mvstep__note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.bo-mvstep__note .bo-input {
|
||||
min-height: 38px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.bo-mvstep[data-status='DONE'] .bo-mvstep__note .bo-input:placeholder-shown {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* документы */
|
||||
.bo-mvdocs {
|
||||
list-style: none;
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
}
|
||||
.bo-mvdocs li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--bc-line);
|
||||
}
|
||||
.bo-mvdocs li:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
.bo-mvdocs label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bo-mvdocs input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--bc-plum);
|
||||
}
|
||||
.bo-mvdocs li[data-checked] span {
|
||||
color: var(--bc-ink-3);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.bo-mvadd {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.bo-mvadd .bo-select {
|
||||
width: auto;
|
||||
min-width: 220px;
|
||||
}
|
||||
.bo-mvadd .bo-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.bo-mvwants {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
/* ───── заявки ───── */
|
||||
.bo-rqgrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.bo-rqlist {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.bo-rq {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--bo-soft);
|
||||
}
|
||||
.bo-rq__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.bo-rq__who {
|
||||
font-size: 14px;
|
||||
}
|
||||
.bo-rq__note {
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--bo-panel);
|
||||
}
|
||||
.bo-rq__notefield {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.bo-rq__notefield .bo-input {
|
||||
min-height: 36px;
|
||||
font-size: 13px;
|
||||
background: var(--bo-panel);
|
||||
}
|
||||
.bo-rq__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.bo-mvrow {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
.bo-mvrow__col {
|
||||
display: none;
|
||||
}
|
||||
.bo-rqgrid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -334,14 +334,14 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
|
||||
}
|
||||
|
||||
/** Открыть переписку с клиентом (или начать её) — в канале, куда он сейчас пишет */
|
||||
function WriteButton({ clientId }: { clientId: string }) {
|
||||
export function WriteButton({ clientId, small }: { clientId: string; small?: boolean }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const open = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError: (e) => toast(errText(t, e), 'error') }));
|
||||
return (
|
||||
<button type="button" className="bo-btn bo-btn--accent" onClick={() => open.mutate({ clientId })} disabled={open.isPending}>
|
||||
<I name="chats" size={16} />
|
||||
<button type="button" className={`bo-btn bo-btn--accent${small ? ' bo-btn--sm' : ''}`} onClick={() => open.mutate({ clientId })} disabled={open.isPending}>
|
||||
<I name="chats" size={small ? 14 : 16} />
|
||||
{t('chat.write')}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useTRPC } from '@barev/web';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { useT, type BoKey } from '../i18n.tsx';
|
||||
import { I, OLD_ADMIN_URL, PlanBadge, Skeleton, sourceKeyLabel } from '../components/kit.tsx';
|
||||
import { I, PlanBadge, Skeleton, sourceKeyLabel } from '../components/kit.tsx';
|
||||
|
||||
type Pair = { orders: number; subs: number };
|
||||
const total = (p: Pair) => p.orders + p.subs;
|
||||
@@ -57,9 +57,9 @@ type Att = { unassigned: number; unreadChats: number; webRequests: number; leads
|
||||
const ATT: { key: keyof Att; href: string; urgent?: boolean; fill: string; ink: string }[] = [
|
||||
{ key: 'unassigned', href: '#/orders?group=new', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
|
||||
{ key: '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: 'webRequests', href: '#/requests', fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
|
||||
{ key: 'leads', href: '#/moves', fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
|
||||
{ key: 'appRequests', href: '#/requests', fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
|
||||
{ key: 'lowRatings', href: '#/providers', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
|
||||
{ key: 'expiring', href: '#/clients?expiring=7', fill: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { formatAmd, type Locale } from '@barev/i18n';
|
||||
import { navigate, paramsSummary, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { I, Skeleton } from '../components/kit.tsx';
|
||||
import { WriteButton } from './ClientCard.tsx';
|
||||
import { NewOrderDialog } from './NewOrder.tsx';
|
||||
|
||||
/* ───────────── вкладки раздела «Заказы» ───────────── */
|
||||
|
||||
/** Заказы · Переезды · Заявки — один раздел; поддержка видит всё, консьерж — переезды и заявки */
|
||||
export function OrdersTabs({ me, current }: { me: Me; current: 'orders' | 'moves' | 'requests' }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const canOrders = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT');
|
||||
const moves = useQuery({ ...trpc.bo.moves.counts.queryOptions(), refetchInterval: 30_000 });
|
||||
const reqs = useQuery({ ...trpc.bo.requests.counts.queryOptions(), refetchInterval: 30_000 });
|
||||
const tabs = [
|
||||
...(canOrders ? [{ id: 'orders' as const, href: '#/orders', n: 0 }] : []),
|
||||
{ id: 'moves' as const, href: '#/moves', n: moves.data?.NEW ?? 0 },
|
||||
{ id: 'requests' as const, href: '#/requests', n: (reqs.data?.site ?? 0) + (reqs.data?.app ?? 0) },
|
||||
];
|
||||
return (
|
||||
<nav className="bo-subtabs" aria-label={t('nav.orders')}>
|
||||
{tabs.map((x) => (
|
||||
<a key={x.id} href={x.href} aria-current={current === x.id ? 'page' : undefined}>
|
||||
{t(`ord.tab.${x.id}`)}
|
||||
{x.n > 0 && <span className="bo-subtabs__n">{x.n}</span>}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── список переездов ───────────── */
|
||||
|
||||
const STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
|
||||
type Status = (typeof STATUSES)[number];
|
||||
export const MOVE_DOT: Record<Status, string> = { NEW: 'var(--bc-accent)', IN_WORK: 'var(--bc-blue-ink)', DONE: 'var(--bc-ok)', CANCELLED: 'var(--bc-ink-3)' };
|
||||
|
||||
function whenText(shared: (k: string) => string, date: (d: Date) => string, m: { moveWhen: string | null; moveDate: Date | null }) {
|
||||
if (m.moveWhen === 'date' && m.moveDate) return date(m.moveDate);
|
||||
return m.moveWhen ? shared(`landing.quiz.when_${m.moveWhen}`) : '—';
|
||||
}
|
||||
|
||||
export function Moves({ me }: { me: Me }) {
|
||||
const { t, shared, date, locale } = useT();
|
||||
const trpc = useTRPC();
|
||||
const { query } = useLocation();
|
||||
const status = (query.get('status') as Status | 'all' | null) ?? 'NEW';
|
||||
const [search, setSearch] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setQ(search.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
const counts = useQuery({ ...trpc.bo.moves.counts.queryOptions(), refetchInterval: 30_000 });
|
||||
const list = useQuery({ ...trpc.bo.moves.list.queryOptions({ status: status === 'all' ? undefined : status, search: q || undefined }), refetchInterval: 30_000, placeholderData: keepPreviousData });
|
||||
const names = useQuery(trpc.bo.moves.names.queryOptions());
|
||||
const stepName = (s: { code: string; title: string | null }) => s.title || names.data?.[`STEP:${s.code}`]?.name[locale] || names.data?.[`STEP:${s.code}`]?.name.ru || s.code;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{t('ord.tab.moves')}</h1>
|
||||
</div>
|
||||
<OrdersTabs me={me} current="moves" />
|
||||
|
||||
<div className="bo-toolbar">
|
||||
<label className="bo-search">
|
||||
<I name="search" size={18} />
|
||||
<input className="bo-input" type="search" value={search} placeholder={t('mv.search')} aria-label={t('mv.search')} onChange={(e) => setSearch(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="bo-ogroups" role="tablist">
|
||||
{[...STATUSES, 'all' as const].map((s) => (
|
||||
<button key={s} type="button" role="tab" aria-selected={status === s} className="bo-ogroup" onClick={() => navigate(s === 'NEW' ? '/moves' : `/moves?status=${s}`, { replace: true })}>
|
||||
{s !== 'all' && <span className="bo-ogroup__dot" style={{ background: MOVE_DOT[s] }} />}
|
||||
{s === 'all' ? t('mv.all') : shared(`admin.leads.status_${s}`)}
|
||||
{s !== 'all' && counts.data && <span className="bo-ogroup__n">{counts.data[s]}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{list.isPending ? (
|
||||
<div className="bo-col">
|
||||
<Skeleton h={72} />
|
||||
<Skeleton h={72} />
|
||||
</div>
|
||||
) : !list.data?.length ? (
|
||||
<div className="bo-panel bo-empty">{t('mv.empty')}</div>
|
||||
) : (
|
||||
<ul className="bo-mvrows">
|
||||
{list.data.map((m) => (
|
||||
<li key={m.id}>
|
||||
<a href={`#/moves/${m.id}`} className="bo-mvrow">
|
||||
<span className="bo-mvrow__main">
|
||||
<span className="bo-mvrow__title">
|
||||
<span className="bo-num bo-faint">№{m.number}</span> {m.name}
|
||||
{m.inApp && <span className="bo-chip bo-chip--ok">{t('mv.inApp')}</span>}
|
||||
</span>
|
||||
<span className="bo-mvrow__sub">
|
||||
{[m.contact, m.agency, m.locale.toUpperCase()].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="bo-mvrow__col">
|
||||
<span className="bo-faint bo-small">{t('mv.when')}</span>
|
||||
<span>{whenText(shared, date, m)}</span>
|
||||
</span>
|
||||
<span className="bo-mvrow__col">
|
||||
<span className="bo-faint bo-small">{m.package ? shared(`landing.quiz.package_${m.package}`) : t('mv.unfinished')}</span>
|
||||
{m.stepsTotal > 0 ? (
|
||||
<>
|
||||
<span className="bo-mvbar" aria-hidden="true">
|
||||
<span style={{ width: `${(m.stepsDone / m.stepsTotal) * 100}%` }} />
|
||||
</span>
|
||||
<span className="bo-small">
|
||||
{t('mv.steps', { done: m.stepsDone, total: m.stepsTotal })}
|
||||
{m.current ? ` · ${t('mv.now', { step: stepName(m.current) })}` : ''}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="bo-faint">{t('mv.noPlan')}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="bo-mvrow__status">
|
||||
<span className="bo-status" style={{ ['--dot' as string]: MOVE_DOT[m.status] }}>
|
||||
{shared(`admin.leads.status_${m.status}`)}
|
||||
</span>
|
||||
{m.docsTotal > 0 && <span className="bo-small bo-faint">{t('mv.docs', { done: m.docsDone, total: m.docsTotal })}</span>}
|
||||
<span className="bo-small bo-faint">{date(m.createdAt)}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── карточка переезда ───────────── */
|
||||
|
||||
const STEP_STATUSES = ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED'] as const;
|
||||
type StepStatus = (typeof STEP_STATUSES)[number];
|
||||
const YES_NO = ['flat', 'socialCard', 'bankCard', 'registration', 'residence'] as const;
|
||||
|
||||
interface Options {
|
||||
airport?: 'none' | 'standard' | 'premium';
|
||||
stay?: 'none' | 'flat' | 'hotel';
|
||||
stayBudgetFrom?: number;
|
||||
stayBudgetTo?: number;
|
||||
flat?: boolean;
|
||||
socialCard?: boolean;
|
||||
bankCard?: boolean;
|
||||
registration?: boolean;
|
||||
residence?: boolean;
|
||||
}
|
||||
|
||||
export function MoveCard({ id, me }: { id: string; me: Me }) {
|
||||
const { t, shared, date, dateTime, locale } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const lead = useQuery(trpc.bo.moves.get.queryOptions({ id }));
|
||||
const names = useQuery(trpc.bo.moves.names.queryOptions());
|
||||
const templates = useQuery(trpc.bo.moves.templates.queryOptions());
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.bo.moves.pathKey() });
|
||||
};
|
||||
const opts = { onSuccess: () => (refresh(), toast(t('prv.saved'))), onError: (e: unknown) => toast(errText(t, e), 'error') };
|
||||
const update = useMutation(trpc.bo.moves.update.mutationOptions(opts));
|
||||
const addStep = useMutation(trpc.bo.moves.addStep.mutationOptions(opts));
|
||||
const addDoc = useMutation(trpc.bo.moves.addDoc.mutationOptions(opts));
|
||||
const updateDoc = useMutation(trpc.bo.moves.updateDoc.mutationOptions(opts));
|
||||
const removeDoc = useMutation(trpc.bo.moves.removeDoc.mutationOptions(opts));
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const canChat = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT') || me.roles.includes('CONCIERGE');
|
||||
|
||||
const back = (
|
||||
<a className="bo-back" href="#/moves">
|
||||
<I name="back" size={16} />
|
||||
{t('mv.back')}
|
||||
</a>
|
||||
);
|
||||
if (lead.isError)
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<div className="bo-panel bo-empty">{errText(t, lead.error)}</div>
|
||||
</>
|
||||
);
|
||||
if (!lead.data)
|
||||
return (
|
||||
<div className="bo-col">
|
||||
<Skeleton h={90} />
|
||||
<Skeleton h={300} />
|
||||
</div>
|
||||
);
|
||||
const d = lead.data;
|
||||
const nameOf = (kind: 'STEP' | 'DOC', it: { code: string; title: string | null }) => {
|
||||
const n = names.data?.[`${kind}:${it.code}`]?.name;
|
||||
return it.title || n?.[locale] || n?.ru || it.code;
|
||||
};
|
||||
const free = (kind: 'STEP' | 'DOC', taken: { code: string }[]) =>
|
||||
(templates.data ?? [])
|
||||
.filter((x) => x.kind === kind && x.active && !taken.some((y) => y.code === x.code))
|
||||
.map((x) => ({ code: x.code, name: (x.name as Record<string, string>)[locale] || (x.name as Record<string, string>).ru || x.code }));
|
||||
const o = (d.options ?? {}) as Options;
|
||||
const wants: string[] = [];
|
||||
if (d.package === 'CUSTOM') {
|
||||
if (o.airport && o.airport !== 'none') wants.push(`${shared('landing.quiz.airport')}: ${shared(`landing.quiz.airport_${o.airport}`)}`);
|
||||
if (o.stay && o.stay !== 'none') {
|
||||
const budget = o.stayBudgetFrom || o.stayBudgetTo ? ` (${shared('admin.leads.budget', { from: o.stayBudgetFrom ? formatAmd(locale as Locale, o.stayBudgetFrom) : '…', to: o.stayBudgetTo ? formatAmd(locale as Locale, o.stayBudgetTo) : '…' })})` : '';
|
||||
wants.push(`${shared('landing.quiz.stay')}: ${shared(`landing.quiz.stay_${o.stay}`)}${budget}`);
|
||||
}
|
||||
for (const k of YES_NO) if (o[k]) wants.push(shared(`landing.quiz.${k}`));
|
||||
}
|
||||
const tel = d.contactType === 'PHONE' ? d.contact.replace(/[^\d+]/g, '') : null;
|
||||
const tg = d.contactType === 'TELEGRAM' ? d.contact.replace(/^@/, '') : null;
|
||||
const steps = d.steps.filter((s) => s.status !== 'SKIPPED');
|
||||
const done = steps.filter((s) => s.status === 'DONE').length;
|
||||
const noteValue = note ?? d.adminNote ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<header className="bo-ohead">
|
||||
<div>
|
||||
<div className="bo-ohead__kicker">
|
||||
№{d.number} · {t('mv.created')} {dateTime(d.createdAt)}
|
||||
</div>
|
||||
<h1 className="bo-title">{d.name}</h1>
|
||||
</div>
|
||||
<div className="bo-pills" role="group" aria-label={t('mv.status')}>
|
||||
{STATUSES.map((s) => (
|
||||
<button key={s} type="button" className="bo-pill" aria-pressed={d.status === s} onClick={() => d.status !== s && update.mutate({ id, status: s })}>
|
||||
{shared(`admin.leads.status_${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="bo-client-grid">
|
||||
<div className="bo-col">
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h">{t('mv.plan')}</h2>
|
||||
{steps.length > 0 && <span className="bo-faint">{t('mv.steps', { done, total: steps.length })}</span>}
|
||||
</div>
|
||||
<p className="bo-hint">{d.client ? t('mv.planHintApp') : t('mv.planHintNoApp')}</p>
|
||||
{steps.length > 0 && (
|
||||
<div className="bo-mvbar bo-mvbar--big" aria-hidden="true">
|
||||
<span style={{ width: `${(done / steps.length) * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<ol className="bo-mvsteps">
|
||||
{d.steps.map((s) => (
|
||||
<StepRow key={s.id} step={s} name={nameOf('STEP', s)} onSaved={refresh} />
|
||||
))}
|
||||
</ol>
|
||||
<AddItem placeholder={t('mv.addStep')} options={free('STEP', d.steps)} onAdd={(v) => addStep.mutate({ id, ...v })} />
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h">{t('mv.docsTitle')}</h2>
|
||||
<span className="bo-faint">
|
||||
{d.docs.filter((x) => x.checked).length}/{d.docs.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="bo-hint">{t('mv.docsHint')}</p>
|
||||
<ul className="bo-mvdocs">
|
||||
{d.docs.map((x) => (
|
||||
<li key={x.id} data-checked={x.checked || undefined}>
|
||||
<label>
|
||||
<input type="checkbox" checked={x.checked} onChange={(e) => updateDoc.mutate({ id: x.id, checked: e.target.checked })} />
|
||||
<span>{nameOf('DOC', x)}</span>
|
||||
</label>
|
||||
<button type="button" className="bo-iconbtn" title={t('mv.remove')} aria-label={t('mv.remove')} onClick={() => confirm(t('mv.removeConfirm', { name: nameOf('DOC', x) })) && removeDoc.mutate({ id: x.id })}>
|
||||
<I name="close" size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<AddItem placeholder={t('mv.addDoc')} options={free('DOC', d.docs)} onAdd={(v) => addDoc.mutate({ id, ...v })} />
|
||||
<p className="bo-hint" style={{ marginTop: 10 }}>
|
||||
{t('mv.listHint')}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="bo-col">
|
||||
<section className="bo-panel">
|
||||
<h2 className="bo-h">{t('mv.contact')}</h2>
|
||||
<div className="bo-contact" style={{ marginTop: 8 }}>
|
||||
<b>{d.contact}</b>
|
||||
<div className="bo-faint bo-small">
|
||||
{t('mv.lang')}: {d.locale.toUpperCase()}
|
||||
{d.agency ? ` · ${t('mv.agency')}: ${d.agency}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bo-row" style={{ flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
|
||||
{d.client && canChat && <WriteButton clientId={d.client.id} />}
|
||||
{tg && (
|
||||
<a className="bo-btn bo-btn--soft" href={`https://t.me/${tg}`} target="_blank" rel="noopener">
|
||||
<I name="telegram" size={16} />
|
||||
Telegram
|
||||
</a>
|
||||
)}
|
||||
{tel && (
|
||||
<a className="bo-btn bo-btn--soft" href={`tel:${tel}`}>
|
||||
<I name="phone" size={16} />
|
||||
{t('mv.call')}
|
||||
</a>
|
||||
)}
|
||||
{d.client && (
|
||||
<a className="bo-btn bo-btn--ghost" href={`#/clients/${d.client.id}`}>
|
||||
{t('mv.openClient')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<h2 className="bo-h">{t('mv.answers')}</h2>
|
||||
<dl className="bo-kv" style={{ marginTop: 8 }}>
|
||||
<dt>{t('mv.when')}</dt>
|
||||
<dd>{whenText(shared, date, d)}</dd>
|
||||
<dt>{t('mv.package')}</dt>
|
||||
<dd>{d.package ? shared(`landing.quiz.package_${d.package}`) : <span className="bo-faint">{t('mv.unfinished')}</span>}</dd>
|
||||
{wants.length > 0 && (
|
||||
<>
|
||||
<dt>{t('mv.wants')}</dt>
|
||||
<dd>
|
||||
<ul className="bo-mvwants">
|
||||
{wants.map((w) => (
|
||||
<li key={w}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<h2 className="bo-h">{t('mv.note')}</h2>
|
||||
<textarea className="bo-textarea" style={{ marginTop: 8 }} rows={4} maxLength={1000} placeholder={t('mv.notePh')} value={noteValue} onChange={(e) => setNote(e.target.value)} />
|
||||
{note !== null && note !== (d.adminNote ?? '') && (
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" style={{ marginTop: 8 }} onClick={() => update.mutate({ id, adminNote: note }, { onSuccess: () => setNote(null) })}>
|
||||
{t('mv.save')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Шаг плана: статус, название, комментарий для клиента */
|
||||
function StepRow({ step, name, onSaved }: { step: { id: string; status: StepStatus; note: string | null }; name: string; onSaved: () => void }) {
|
||||
const { t, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const [note, setNote] = useState(step.note ?? '');
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
useEffect(() => setNote(step.note ?? ''), [step.note]);
|
||||
const opts = { onSuccess: () => (onSaved(), toast(t('prv.saved'))), onError: (e: unknown) => toast(errText(t, e), 'error') };
|
||||
const save = useMutation(trpc.bo.moves.updateStep.mutationOptions(opts));
|
||||
const remove = useMutation(trpc.bo.moves.removeStep.mutationOptions(opts));
|
||||
return (
|
||||
<li className="bo-mvstep" data-status={step.status}>
|
||||
<span className="bo-mvstep__dot" aria-hidden="true">
|
||||
{step.status === 'DONE' && <I name="check" size={12} stroke={3} />}
|
||||
</span>
|
||||
<div className="bo-mvstep__body">
|
||||
<div className="bo-mvstep__top">
|
||||
{title === null ? (
|
||||
<button type="button" className="bo-mvstep__name" title={t('mv.rename')} onClick={() => setTitle(name)}>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
className="bo-input"
|
||||
autoFocus
|
||||
value={title}
|
||||
maxLength={120}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (title.trim() && title.trim() !== name) save.mutate({ id: step.id, title: title.trim() });
|
||||
setTitle(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
if (e.key === 'Escape') setTitle(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<select className="bo-select" value={step.status} aria-label={name} onChange={(e) => save.mutate({ id: step.id, status: e.target.value as StepStatus })}>
|
||||
{STEP_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{shared(`client.move.status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="bo-iconbtn" title={t('mv.remove')} aria-label={t('mv.remove')} onClick={() => confirm(t('mv.removeConfirm', { name })) && remove.mutate({ id: step.id })}>
|
||||
<I name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="bo-mvstep__note">
|
||||
<input className="bo-input" value={note} maxLength={500} placeholder={t('mv.stepNote')} onChange={(e) => setNote(e.target.value)} />
|
||||
{note !== (step.note ?? '') && (
|
||||
<button type="button" className="bo-btn bo-btn--sm" onClick={() => save.mutate({ id: step.id, note })}>
|
||||
{t('mv.save')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Добавить пункт: из общего списка или свой */
|
||||
function AddItem({ placeholder, options, onAdd }: { placeholder: string; options: { code: string; name: string }[]; onAdd: (v: { code?: string; title?: string }) => void }) {
|
||||
const { t } = useT();
|
||||
const [value, setValue] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
return (
|
||||
<div className="bo-mvadd">
|
||||
<select className="bo-select" value={value} onChange={(e) => setValue(e.target.value)} aria-label={placeholder}>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.code} value={o.code}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom">{t('mv.custom')}</option>
|
||||
</select>
|
||||
{value === '__custom' && <input className="bo-input" value={title} maxLength={120} placeholder={t('mv.customPh')} onChange={(e) => setTitle(e.target.value)} />}
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--sm bo-btn--accent"
|
||||
disabled={value === '__custom' && !title.trim()}
|
||||
onClick={() => {
|
||||
onAdd(value === '__custom' ? { title: title.trim() } : { code: value });
|
||||
setValue('');
|
||||
setTitle('');
|
||||
}}
|
||||
>
|
||||
<I name="plus" size={14} />
|
||||
{t('mv.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── заявки ───────────── */
|
||||
|
||||
export function Requests({ me }: { me: Me }) {
|
||||
const { t, shared, sharedT, dateTime, amd } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const { query } = useLocation();
|
||||
const open = query.get('closed') !== '1';
|
||||
const site = useQuery({ ...trpc.bo.requests.site.queryOptions({ open }), refetchInterval: 30_000 });
|
||||
const app = useQuery({ ...trpc.bo.requests.app.queryOptions({ open }), refetchInterval: 30_000 });
|
||||
const [orderFor, setOrderFor] = useState<string | null>(null);
|
||||
const opts = (msg = t('prv.saved')) => ({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.bo.requests.pathKey() });
|
||||
toast(msg);
|
||||
},
|
||||
onError: (e: unknown) => toast(errText(t, e), 'error'),
|
||||
});
|
||||
const updateSite = useMutation(trpc.bo.requests.updateSite.mutationOptions(opts()));
|
||||
const updateApp = useMutation(trpc.bo.requests.updateApp.mutationOptions(opts()));
|
||||
const activate = useMutation(trpc.bo.requests.activate.mutationOptions(opts(t('rq.activated'))));
|
||||
const planName = (p: string | null) => (p === 'ELITE' ? 'Barev Elite' : 'Barev+');
|
||||
const person = (u: { firstName: string | null; lastName: string | null; username: string | null } | undefined) => [u?.firstName, u?.lastName].filter(Boolean).join(' ') || (u?.username ? `@${u.username}` : '—');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{t('ord.tab.requests')}</h1>
|
||||
<div className="bo-pills" role="group">
|
||||
<button type="button" className="bo-pill" aria-pressed={open} onClick={() => navigate('/requests', { replace: true })}>
|
||||
{t('rq.open')}
|
||||
</button>
|
||||
<button type="button" className="bo-pill" aria-pressed={!open} onClick={() => navigate('/requests?closed=1', { replace: true })}>
|
||||
{t('rq.closed')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<OrdersTabs me={me} current="requests" />
|
||||
|
||||
<div className="bo-client-grid bo-rqgrid">
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h">{t('rq.site')}</h2>
|
||||
<span className="bo-faint">{site.data?.length ?? ''}</span>
|
||||
</div>
|
||||
<p className="bo-hint">{t('rq.siteHint')}</p>
|
||||
{site.isPending ? (
|
||||
<Skeleton h={80} />
|
||||
) : !site.data?.length ? (
|
||||
<div className="bo-empty">{open ? t('rq.empty') : t('rq.emptyClosed')}</div>
|
||||
) : (
|
||||
<ul className="bo-rqlist">
|
||||
{site.data.map((r) => (
|
||||
<li key={r.id} className="bo-rq">
|
||||
<div className="bo-rq__top">
|
||||
<b>
|
||||
<span className="bo-num bo-faint">№{r.number}</span> {shared(`services.${r.service.code}.name`)}
|
||||
</b>
|
||||
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
|
||||
</div>
|
||||
<div className="bo-small">{paramsSummary(sharedT, r.service.code, r.params as Record<string, unknown>)}</div>
|
||||
<div className="bo-small">
|
||||
{t('rq.wish')}: <b>{dateTime(r.preferredAt)}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
|
||||
</div>
|
||||
<div className="bo-small bo-faint">
|
||||
{shared(`districts.${r.district.code}`)}, {r.addressText}
|
||||
</div>
|
||||
<div className="bo-rq__who">
|
||||
<span>
|
||||
{r.name || person(r.client?.user)} · <a href={`tel:${r.phone.replace(/[^\d+]/g, '')}`}>{r.phone}</a>
|
||||
{!r.client && <span className="bo-faint"> · {t('rq.noApp')}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{r.note && <div className="bo-small bo-rq__note">{r.note}</div>}
|
||||
<NoteLine value={r.adminNote} onSave={(adminNote) => updateSite.mutate({ id: r.id, adminNote })} />
|
||||
<div className="bo-rq__actions">
|
||||
{r.order ? (
|
||||
<a className="bo-btn bo-btn--sm bo-btn--soft" href={`#/orders/${r.order.id}`}>
|
||||
{t('rq.order', { n: r.order.number })}
|
||||
</a>
|
||||
) : (
|
||||
r.status !== 'CANCELLED' &&
|
||||
r.client && (
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" onClick={() => setOrderFor(r.client!.id)}>
|
||||
<I name="plus" size={14} />
|
||||
{t('rq.newOrder')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{r.client && (
|
||||
<a className="bo-btn bo-btn--sm bo-btn--ghost" href={`#/clients/${r.client.id}`}>
|
||||
{t('mv.openClient')}
|
||||
</a>
|
||||
)}
|
||||
{r.status === 'CANCELLED' ? (
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateSite.mutate({ id: r.id, status: 'REOPEN' })}>
|
||||
{t('rq.reopen')}
|
||||
</button>
|
||||
) : (
|
||||
r.status !== 'ORDERED' && (
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => confirm(shared('admin.webRequests.cancelConfirm', { number: r.number })) && updateSite.mutate({ id: r.id, status: 'CANCELLED' })}>
|
||||
{t('rq.cancel')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="bo-faint bo-small">{dateTime(r.createdAt)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h">{t('rq.app')}</h2>
|
||||
<span className="bo-faint">{app.data?.length ?? ''}</span>
|
||||
</div>
|
||||
<p className="bo-hint">{t('rq.appHint')}</p>
|
||||
{app.isPending ? (
|
||||
<Skeleton h={80} />
|
||||
) : !app.data?.length ? (
|
||||
<div className="bo-empty">{open ? t('rq.empty') : t('rq.emptyClosed')}</div>
|
||||
) : (
|
||||
<ul className="bo-rqlist">
|
||||
{app.data.map((r) => (
|
||||
<li key={r.id} className="bo-rq">
|
||||
<div className="bo-rq__top">
|
||||
<b>
|
||||
<span className="bo-num bo-faint">№{r.number}</span>{' '}
|
||||
{r.kind === 'SUBSCRIPTION' ? shared('admin.requests.subscription', { plan: planName(r.plan) }) : t('rq.callback', { service: r.serviceCode ? shared(`services.${r.serviceCode}.name`) : '—' })}
|
||||
</b>
|
||||
<span className="bo-chip">{shared(`admin.requests.status_${r.status}`)}</span>
|
||||
</div>
|
||||
<div className="bo-rq__who">
|
||||
<a href={`#/clients/${r.clientId}`}>{person(r.client.user)}</a>
|
||||
{r.client.user.phone && (
|
||||
<>
|
||||
{' · '}
|
||||
<a href={`tel:${r.client.user.phone}`}>{r.client.user.phone}</a>
|
||||
</>
|
||||
)}
|
||||
<span className="bo-faint"> · {r.client.user.locale.toUpperCase()}</span>
|
||||
</div>
|
||||
{r.comment && <div className="bo-small bo-rq__note">{r.comment}</div>}
|
||||
<NoteLine value={r.adminNote} onSave={(adminNote) => updateApp.mutate({ id: r.id, adminNote })} />
|
||||
<div className="bo-rq__actions">
|
||||
{r.status === 'NEW' && r.kind === 'SUBSCRIPTION' && (
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" onClick={() => confirm(shared('admin.requests.activateConfirm', { plan: planName(r.plan) })) && activate.mutate({ id: r.id, days: 30 })}>
|
||||
{t('rq.activate')}
|
||||
</button>
|
||||
)}
|
||||
{r.status === 'NEW' && (
|
||||
<WriteButton clientId={r.clientId} small />
|
||||
)}
|
||||
{r.status === 'NEW' ? (
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--soft" onClick={() => updateApp.mutate({ id: r.id, status: 'DONE' })}>
|
||||
<I name="check" size={14} />
|
||||
{t('rq.done')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateApp.mutate({ id: r.id, status: 'CANCELLED' })}>
|
||||
{t('rq.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateApp.mutate({ id: r.id, status: 'NEW' })}>
|
||||
{t('rq.reopen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="bo-faint bo-small">{dateTime(r.createdAt)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{orderFor && <NewOrderDialog clientId={orderFor} onClose={() => setOrderFor(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Заметка команды одной строкой: сохраняется по кнопке */
|
||||
function NoteLine({ value, onSave }: { value: string | null; onSave: (v: string) => void }) {
|
||||
const { t } = useT();
|
||||
const [v, setV] = useState(value ?? '');
|
||||
useEffect(() => setV(value ?? ''), [value]);
|
||||
return (
|
||||
<div className="bo-rq__notefield">
|
||||
<input className="bo-input" value={v} maxLength={1000} placeholder={t('mv.notePh')} aria-label={t('mv.note')} onChange={(e) => setV(e.target.value)} />
|
||||
{v !== (value ?? '') && (
|
||||
<button type="button" className="bo-btn bo-btn--sm" onClick={() => onSave(v)}>
|
||||
{t('mv.save')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { Me } from '../App.tsx';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { I, Skeleton } from '../components/kit.tsx';
|
||||
import { NewOrderDialog } from './NewOrder.tsx';
|
||||
import { OrdersTabs } from './Moves.tsx';
|
||||
|
||||
type Group = 'active' | 'new' | 'pending' | 'assigned' | 'in_progress' | 'done' | 'cancelled' | 'all';
|
||||
type Period = 'all' | 'today' | 'tomorrow' | 'week' | 'past';
|
||||
@@ -85,6 +86,8 @@ export function Orders({ me }: { me: Me }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrdersTabs me={me} current="orders" />
|
||||
|
||||
{!!c?.needsDispatcher && group !== 'new' && (
|
||||
<button type="button" className="bo-alert" onClick={() => set({ group: 'new' })}>
|
||||
<I name="warn" size={18} />
|
||||
|
||||
Reference in new issue
Block a user