Chats: show which order a provider writes about, or that there is no order

- Provider messages store the order that was current when they wrote: a started
  order first, otherwise the nearest assigned one within -4h..+12h; none — "no order".
- Admin chat: order tag above each provider message, "now / next" order strip in
  the conversation header, order number (or "no order") in the thread list.
- Telegram notification to the team gets an order line or "no current order".
- Test for the order-picking rule; demo shows both cases.

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:02:31 +04:00
1 parent 23ffb2bf1d
commit 2772837e29
14 files changed
+321 -13

No files matched your search

+40
View File
@@ -379,6 +379,46 @@
background: var(--bc-learn);
color: var(--bc-learn-ink);
}
/* исполнитель: текущий заказ в шапке и заказ над каждым его сообщением */
.bo-conv__banner--order {
background: var(--bc-docs);
color: var(--bc-docs-ink);
align-items: flex-start;
}
.bo-conv__banner--order.is-free {
background: var(--bo-soft);
color: var(--bc-ink);
}
.bo-conv__banner--order svg {
flex: none;
margin-top: 2px;
}
.bo-conv__next {
display: block;
font-size: 13px;
opacity: 0.75;
}
.bo-ordertag {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
margin: -2px 0 6px;
padding: 3px 9px;
border-radius: var(--bc-pill);
background: var(--bc-docs);
color: var(--bc-docs-ink);
font-size: 12px;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-ordertag--none {
background: var(--bo-soft);
color: var(--bc-ink-2);
}
.bo-conv__closed {
padding: 8px 18px;
font-size: 13px;
+24
View File
@@ -386,6 +386,14 @@ const ru = {
'chat.notLinked': 'Рабочий бот не открыт',
'chat.details': 'Подробнее',
'chat.more': 'Ещё действия',
'chat.order': 'Заказ №{n}',
'chat.noOrder': 'Без заказа',
'chat.noOrderNow': 'Сейчас без заказа',
'chat.orderNow': 'Сейчас',
'chat.orderNext': 'Ближайший',
'chat.orderNoNext': 'Ближайших заказов нет',
'chat.orderClient': 'клиент {name}',
'chat.wroteNoOrder': 'писал(а) без заказа',
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'Консьерж — только для подписчиков. Сначала выдайте клиенту подписку.',
'err.ASSIGNEE_NO_ACCESS': 'У этого сотрудника нет доступа к этому каналу.',
'err.PROVIDER_NOT_LINKED': 'Исполнитель ещё не открыл рабочий бот — написать ему пока некуда.',
@@ -784,6 +792,14 @@ const en: Dict = {
'chat.notLinked': 'Work bot not opened',
'chat.details': 'Details',
'chat.more': 'More actions',
'chat.order': 'Order #{n}',
'chat.noOrder': 'No order',
'chat.noOrderNow': 'No current order',
'chat.orderNow': 'Now',
'chat.orderNext': 'Next',
'chat.orderNoNext': 'No upcoming orders',
'chat.orderClient': 'client {name}',
'chat.wroteNoOrder': 'wrote without an order',
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'The concierge is for members only. Give the client a subscription first.',
'err.ASSIGNEE_NO_ACCESS': 'This team member has no access to this channel.',
'err.PROVIDER_NOT_LINKED': 'The provider hasn’t opened the work bot yet.',
@@ -1179,6 +1195,14 @@ const hy: Dict = {
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
'chat.details': 'Մանրամասներ',
'chat.more': 'Այլ գործողություններ',
'chat.order': 'Պատվեր №{n}',
'chat.noOrder': 'Առանց պատվերի',
'chat.noOrderNow': 'Այժմ պատվեր չկա',
'chat.orderNow': 'Այժմ',
'chat.orderNext': 'Հաջորդը',
'chat.orderNoNext': 'Մոտակա պատվերներ չկան',
'chat.orderClient': 'հաճախորդ {name}',
'chat.wroteNoOrder': 'գրել է առանց պատվերի',
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'Կոնսիերժը միայն բաժանորդների համար է։ Նախ տրամադրեք բաժանորդագրություն։',
'err.ASSIGNEE_NO_ACCESS': 'Այս աշխատակիցը մուտք չունի այս ալիքին։',
'err.PROVIDER_NOT_LINKED': 'Կատարողը դեռ չի բացել աշխատանքային բոտը։',
+80
View File
@@ -169,6 +169,8 @@ type ListItem = {
waitingSince: Date | null;
lastMessageAt: Date;
last: { author: string; text: string; file: string | null } | null;
/** исполнитель: номер заказа последнего сообщения, null — без заказа */
lastOrder?: number | null;
unread: number;
};
@@ -204,6 +206,7 @@ function ThreadList({ items, loading, activeId, showChannel, meId, filtered }: {
</span>
<span className="bo-thread__meta">
{showChannel && <span className={`bo-chip bo-chip--${it.channel}`}>{t(`channel.${it.channel}`)}</span>}
{it.lastOrder !== undefined && <span className="bo-chip">{it.lastOrder ? t('chat.order', { n: it.lastOrder }) : t('chat.noOrder')}</span>}
{it.waitingSince && <span className={`bo-wait-chip bo-wait-chip--${urgency(it.waitingSince)}`}>{t('chat.waiting', { t: ago(t, it.waitingSince) })}</span>}
{it.botActive && (
<span className="bo-chip">
@@ -334,6 +337,7 @@ function Conversation({ id, me, channels, side, onToggleSide }: { id: string; me
</div>
</header>
{d.providerId && <ProviderOrders current={d.currentOrder} next={d.nextOrder} />}
{d.botActive && (
<div className="bo-conv__banner bo-conv__banner--bot">
<I name="bot" size={18} />
@@ -379,6 +383,7 @@ type Message = {
param: string | null;
deliveryError: string | null;
createdAt: Date;
order?: OrderBrief | null;
};
function Messages({ messages, locale, peerName }: { messages: Message[]; locale: Locale; peerName: string }) {
@@ -430,6 +435,7 @@ function Messages({ messages, locale, peerName }: { messages: Message[]; locale:
<Fragment key={m.id}>
{sep}
<div className={`bo-msg bo-msg--${kind}`}>
{m.author === 'PROVIDER' && m.order !== undefined && <OrderTag order={m.order} />}
{kind === 'note' && (
<div className="bo-msg__label">
<I name="lock" size={13} />
@@ -478,6 +484,80 @@ function FileView({ id, file }: { id: string; file: { name: string | null; mime:
);
}
// ───── заказ исполнителя ─────
type OrderBrief = {
id: string;
number: number;
service: string;
flow: string;
status: string;
scheduledAt: Date;
district: string | null;
address: string | null;
isOnline: boolean;
clientId: string;
clientName: string;
};
/** «Заказ №19 · Клининг · ср, 21 окт., 14:00 · Кентрон» */
function useOrderText() {
const { t, shared, locale } = useT();
return (o: OrderBrief, full = false) =>
[
t('chat.order', { n: o.number }),
shared(`services.${o.service}.name`),
formatDateTime(locale as Locale, o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
o.isOnline ? null : full && o.address ? o.address : o.district ? shared(`districts.${o.district}`) : null,
full ? shared(`status.${o.flow}.${o.status}`) : null,
full && o.clientName ? t('chat.orderClient', { name: o.clientName }) : null,
]
.filter(Boolean)
.join(' · ');
}
/** Над сообщением исполнителя: по какому заказу он писал, или что заказа не было */
function OrderTag({ order }: { order: OrderBrief | null }) {
const { t } = useT();
const text = useOrderText();
if (!order)
return (
<div className="bo-ordertag bo-ordertag--none">
<I name="orders" size={13} />
{t('chat.wroteNoOrder')}
</div>
);
return (
<a className="bo-ordertag" href={`#/clients/${order.clientId}`} title={t('chat.orderClient', { name: order.clientName })}>
<I name="orders" size={13} />
{text(order)}
</a>
);
}
/** Шапка диалога с исполнителем: что у него сейчас и что дальше */
function ProviderOrders({ current, next }: { current: OrderBrief | null; next: OrderBrief | null }) {
const { t } = useT();
const text = useOrderText();
return (
<div className={`bo-conv__banner bo-conv__banner--order${current ? '' : ' is-free'}`}>
<I name="orders" size={18} />
<span className="bo-grow" style={{ minWidth: 0 }}>
{current ? (
<>
<b>{t('chat.orderNow')}:</b> {text(current, true)}
</>
) : (
<b>{t('chat.noOrderNow')}</b>
)}
<span className="bo-conv__next">
{next ? `${t('chat.orderNext')}: ${text(next)}` : t('chat.orderNoNext')}
</span>
</span>
</div>
);
}
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
// ───── поле ответа ─────