Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { LOCALE_LABELS, LOCALES, type Locale } from '@barev/i18n';
import { Button, Center, Chip, Icon, Logo, Spinner, type IconName } from '@barev/ui';
import { I18nProvider, pickLocale, useI18n, useLocation, useTRPC } from '@barev/web';
import { useState, type ReactNode } from 'react';
import { Login } from './pages/Login.tsx';
import { Kanban } from './pages/Kanban.tsx';
import { Providers } from './pages/Providers.tsx';
import { Presets } from './pages/Presets.tsx';
import { Companies } from './pages/Companies.tsx';
import { Services } from './pages/Services.tsx';
import { Agencies } from './pages/Agencies.tsx';
import { Clients } from './pages/Clients.tsx';
import { Settings } from './pages/Settings.tsx';
import { WebRequests } from './pages/WebRequests.tsx';
import { Leads } from './pages/Leads.tsx';
import { Requests } from './pages/Requests.tsx';
import { Concierge } from './pages/Concierge.tsx';
import { Partners } from './pages/Partners.tsx';
import { Templates } from './pages/Templates.tsx';
import { Telegram } from './pages/Telegram.tsx';
const LOCALE_KEY = 'barev.admin.locale';
function savedLocale(): Locale {
try {
return pickLocale(localStorage.getItem(LOCALE_KEY), navigator.language);
} catch {
return 'ru';
}
}
export function App() {
const [locale] = useState(savedLocale);
return (
<I18nProvider initial={locale}>
<Gate />
</I18nProvider>
);
}
function Gate() {
const trpc = useTRPC();
const me = useQuery({ ...trpc.admin.auth.me.queryOptions(), retry: false });
if (me.isPending)
return (
<Center>
<Spinner />
</Center>
);
if (!me.data || !me.data.verified) return <Login stage={me.data ? (me.data.totpEnabled ? 'totp' : 'totp_setup') : 'password'} />;
return <Shell role={me.data.role} name={me.data.name} />;
}
const NAV: { path: string; key: string; icon: IconName; owner?: boolean }[] = [
{ path: '/', key: 'admin.nav.orders', icon: 'grid' },
{ path: '/leads', key: 'admin.nav.leads', icon: 'globe' },
{ path: '/templates', key: 'admin.nav.templates', icon: 'docs' },
{ path: '/concierge', key: 'admin.nav.concierge', icon: 'chat' },
{ path: '/app-requests', key: 'admin.nav.requests', icon: 'star' },
{ path: '/requests', key: 'admin.nav.webRequests', icon: 'chat' },
{ path: '/clients', key: 'admin.nav.clients', icon: 'user' },
{ path: '/providers', key: 'admin.nav.providers', icon: 'star' },
{ path: '/presets', key: 'admin.nav.presets', icon: 'repeat' },
{ path: '/companies', key: 'admin.nav.companies', icon: 'home' },
{ path: '/services', key: 'admin.nav.services', icon: 'docs' },
{ path: '/agencies', key: 'admin.nav.agencies', icon: 'gift' },
{ path: '/partners', key: 'admin.nav.partners', icon: 'gift' },
{ path: '/telegram', key: 'admin.nav.telegram', icon: 'chat' },
{ path: '/settings', key: 'admin.nav.settings', icon: 'clock', owner: true },
];
function Shell({ role, name }: { role: 'OWNER' | 'OPERATOR'; name: string }) {
const { t, locale, setLocale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const { path } = useLocation();
const logout = useMutation(trpc.admin.auth.logout.mutationOptions({ onSuccess: () => qc.invalidateQueries() }));
const isOwner = role === 'OWNER';
// новые заявки с сайта — счётчик в меню, обновляется сам
const openRequests = useQuery({ ...trpc.admin.webRequests.openCount.queryOptions(), refetchInterval: 30_000 });
const openLeads = useQuery({ ...trpc.admin.leads.openCount.queryOptions(), refetchInterval: 30_000 });
const openAppRequests = useQuery({ ...trpc.admin.requests.openCount.queryOptions(), refetchInterval: 30_000 });
const unreadChats = useQuery({ ...trpc.admin.concierge.unreadCount.queryOptions(), refetchInterval: 15_000 });
const counts: Record<string, number | undefined> = { '/requests': openRequests.data, '/leads': openLeads.data, '/app-requests': openAppRequests.data, '/concierge': unreadChats.data };
let page: ReactNode;
if (path === '/leads') page = <Leads />;
else if (path === '/concierge') page = <Concierge />;
else if (path === '/app-requests') page = <Requests />;
else if (path === '/partners') page = <Partners />;
else if (path === '/templates') page = <Templates />;
else if (path === '/telegram') page = <Telegram />;
else if (path === '/requests') page = <WebRequests />;
else if (path === '/clients') page = <Clients isOwner={isOwner} />;
else if (path === '/providers') page = <Providers />;
else if (path === '/presets') page = <Presets isOwner={isOwner} />;
else if (path === '/companies') page = <Companies />;
else if (path === '/services') page = <Services isOwner={isOwner} />;
else if (path === '/agencies') page = <Agencies isOwner={isOwner} />;
else if (path === '/settings' && isOwner) page = <Settings />;
else page = <Kanban />;
return (
<div className="layout">
<aside className="sidebar">
<div style={{ padding: '4px 10px 16px' }}>
<Logo size={22} />
</div>
{NAV.filter((n) => !n.owner || isOwner).map((n) => (
<a key={n.path} href={`#${n.path}`} aria-current={path === n.path ? 'page' : undefined}>
<Icon name={n.icon} size={18} />
{t(n.key)}
{!!counts[n.path] && <span className="nav-count">{counts[n.path]}</span>}
</a>
))}
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 8, padding: '0 6px' }}>
<div className="bc-chips">
{LOCALES.map((l) => (
<Chip
key={l}
pressed={l === locale}
onClick={() => {
setLocale(l);
try {
localStorage.setItem(LOCALE_KEY, l);
} catch {
/* приватный режим */
}
}}
style={{ minHeight: 32, padding: '4px 10px', fontSize: 12 }}
>
{LOCALE_LABELS[l]}
</Chip>
))}
</div>
<div className="bc-small bc-muted">
{name} · {t(`admin.roles.${role}`)}
</div>
<Button size="sm" variant="outline" onClick={() => logout.mutate()}>
{t('admin.logout')}
</Button>
</div>
</aside>
<main className="main">{page}</main>
</div>
);
}
+374
View File
@@ -0,0 +1,374 @@
body {
font-size: 14px;
}
.layout {
display: grid;
grid-template-columns: 232px 1fr;
min-height: 100vh;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 20px 14px;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--bc-surface);
border-right: 1px solid var(--bc-line);
}
.sidebar a {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 0 14px;
border-radius: 999px;
text-decoration: none;
font-weight: 500;
}
.sidebar a[aria-current='page'] {
background: var(--bc-dark);
color: #fff;
}
/* счётчик новых заявок с сайта */
.nav-count {
margin-left: auto;
min-width: 22px;
height: 22px;
padding: 0 7px;
border-radius: 999px;
background: var(--bc-accent);
color: #fff;
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.main {
padding: 24px 28px 48px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.toolbar .bc-input {
width: auto;
min-height: 44px;
padding: 8px 14px;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.summary .bc-card {
padding: 16px 18px;
}
.summary__value {
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 28px;
}
.kanban {
display: grid;
grid-template-columns: repeat(var(--cols, 4), minmax(240px, 1fr));
gap: 12px;
align-items: start;
overflow-x: auto;
}
.column {
background: #f3ece1;
border-radius: 24px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 200px;
}
.column__head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 6px 6px;
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 16px;
}
.ocard {
text-align: left;
border: 2px solid transparent;
background: var(--bc-surface);
border-radius: 18px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
width: 100%;
}
.ocard:hover {
border-color: var(--bc-ink);
}
.ocard__stripe {
height: 6px;
width: 36px;
border-radius: 999px;
}
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(30, 27, 26, 0.35);
z-index: 60;
display: flex;
justify-content: flex-end;
}
.drawer {
width: min(680px, 100%);
height: 100%;
overflow: auto;
background: var(--bc-bg);
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
table.grid {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background: var(--bc-surface);
border-radius: 20px;
overflow: hidden;
}
table.grid th,
table.grid td {
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--bc-line);
vertical-align: top;
}
table.grid th {
font-size: 12px;
font-weight: 600;
color: var(--bc-ink-2);
background: #f8f3eb;
}
table.grid tr:last-child td {
border-bottom: none;
}
table.grid tr.clickable {
cursor: pointer;
}
table.grid tr.clickable:hover td {
background: #fcfaf6;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-grid .full {
grid-column: 1 / -1;
}
.schedule {
display: grid;
grid-template-columns: 70px 1fr 1fr 60px;
gap: 6px;
align-items: center;
}
.schedule .bc-input {
min-height: 40px;
padding: 6px 10px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
}
.candidate {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 16px;
background: var(--bc-surface);
}
.history-item {
display: grid;
grid-template-columns: 130px 1fr;
gap: 10px;
font-size: 13px;
padding: 6px 0;
border-bottom: 1px dashed var(--bc-line);
}
.auth-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.auth-card {
width: min(420px, 100%);
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
flex-direction: row;
flex-wrap: wrap;
}
.main {
padding: 16px;
}
.form-grid {
grid-template-columns: 1fr;
}
}
/* шаги плана переезда в карточке заявки */
.lead-steps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 8px;
}
.lead-step {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border-radius: 14px;
background: var(--bc-surface);
}
/* чат консьержа */
.chat {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 12px;
min-height: 70vh;
}
.chat__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.chat__item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 12px;
border: 1.5px solid transparent;
border-radius: 16px;
background: var(--bc-surface);
font: inherit;
text-align: left;
cursor: pointer;
}
.chat__item[aria-current='true'] {
border-color: var(--bc-plum);
}
.chat__preview {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.chat__body {
display: flex;
flex-direction: column;
border-radius: 20px;
background: var(--bc-surface);
overflow: hidden;
}
.chat__head {
display: flex;
gap: 12px;
align-items: baseline;
padding: 14px 18px;
border-bottom: 1px solid var(--bc-line);
}
.chat__messages {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
overflow-y: auto;
max-height: 60vh;
background: var(--bc-bg);
}
.msg {
max-width: 70%;
padding: 10px 14px;
border-radius: 18px 18px 18px 6px;
background: var(--bc-surface);
white-space: pre-wrap;
}
.msg span {
display: block;
margin-top: 4px;
opacity: 0.6;
}
.msg[data-author='OPERATOR'] {
align-self: flex-end;
border-radius: 18px 18px 6px 18px;
background: var(--bc-plum);
color: #fff;
}
.chat__form {
display: flex;
gap: 8px;
align-items: flex-end;
padding: 12px;
border-top: 1px solid var(--bc-line);
}
.chat__form textarea {
flex: 1;
min-height: 56px;
}
.lead-plan {
display: flex;
flex-direction: column;
gap: 8px;
}
.lead-docs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 4px 12px;
}
.lead-doc {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
}
.lead-title {
flex: 1;
font-weight: 600;
cursor: text;
}
.lead-x {
border: 0;
background: none;
font-size: 20px;
line-height: 1;
color: var(--bc-ink-3);
cursor: pointer;
}
.lead-x:hover {
color: var(--bc-danger);
}
+282
View File
@@ -0,0 +1,282 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Badge, Button, Card, Field, Icon, Spinner } from '@barev/ui';
import { confirmDialog, errorText, paramsExtras, paramsSummary, useI18n, useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
/** ADM-3, ADM-4: карточка заказа и назначение исполнителя */
export function OrderDrawer({ id, onClose }: { id: string; onClose: () => void }) {
const { t, amd, dt, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const order = useQuery({ ...trpc.admin.orders.get.queryOptions({ id }), refetchInterval: 10_000 });
const methods = useQuery(trpc.admin.dictionaries.paymentMethods.queryOptions());
const [comment, setComment] = useState('');
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const done = (msg: string) => () => {
toast(msg);
void qc.invalidateQueries();
};
const fail = (e: unknown) => toast(errorText(t, e), 'error');
const transition = useMutation(trpc.admin.orders.transition.mutationOptions({ onSuccess: done(t('admin.order.statusChanged')), onError: fail }));
const cancel = useMutation(trpc.admin.orders.cancel.mutationOptions({ onSuccess: done(t('admin.order.cancelled')), onError: fail }));
const unassign = useMutation(trpc.admin.orders.unassign.mutationOptions({ onSuccess: done(t('admin.order.unassigned')), onError: fail }));
const markPaid = useMutation(trpc.admin.orders.markPaid.mutationOptions({ onSuccess: done(t('admin.order.paid')), onError: fail }));
const o = order.data;
const pendingOffer = o?.offers.find((f) => f.status === 'OFFERED');
const canAssign = o && !o.providerId && !pendingOffer && o.status === o.flowSteps[0];
const isFinal = o && !o.canCancel;
return (
<div className="drawer-backdrop" onClick={onClose}>
<aside className="drawer" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
{!o ? (
<Spinner />
) : (
<>
<div className="bc-between">
<h2 className="bc-h2">
#{o.number} · {t(`services.${o.service.code}.name`)}
</h2>
<button className="bc-btn bc-btn--light bc-btn--sm" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" size={18} />
</button>
</div>
<div className="bc-row" style={{ flexWrap: 'wrap' }}>
<Badge style={{ background: 'var(--bc-dark)', color: '#fff' }}>{t(`status.${o.service.flow}.${o.status}`)}</Badge>
<Badge>{t(`payment.${o.paymentStatus}`)}</Badge>
{pendingOffer && (
<Badge style={{ background: 'var(--bc-perks)' }}>
{pendingOffer.company && !pendingOffer.provider
? t('admin.kanban.toCompany', { name: pendingOffer.company.name })
: t('admin.kanban.awaiting', { name: pendingOffer.provider?.name ?? '' })}
</Badge>
)}
</div>
<Card className="bc-stack">
<dl className="kv" style={{ margin: 0, display: 'grid', gridTemplateColumns: '160px 1fr', gap: '6px 12px' }}>
<dt className="bc-muted">{t('admin.order.when')}</dt>
<dd style={{ margin: 0 }}>
{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} · {o.durationMinutes} {t('admin.order.min')}
</dd>
<dt className="bc-muted">{t('admin.order.params')}</dt>
<dd style={{ margin: 0 }}>
{paramsSummary(t, o.service.code, o.params as Record<string, unknown>)}
{paramsExtras(t, o.params as Record<string, unknown>).map((e) => (
<div key={e.code} className="bc-small">
+ {e.label}
</div>
))}
</dd>
<dt className="bc-muted">{t('admin.order.address')}</dt>
<dd style={{ margin: 0 }}>
{o.isOnline ? t('common.online') : `${o.district ? t(`districts.${o.district.code}`) + ', ' : ''}${o.addressText ?? ''}`}
</dd>
{o.note && (
<>
<dt className="bc-muted">{t('admin.order.note')}</dt>
<dd style={{ margin: 0 }}>{o.note}</dd>
</>
)}
<dt className="bc-muted">{t('admin.order.client')}</dt>
<dd style={{ margin: 0 }}>
{o.client.user.firstName} {o.client.user.lastName ?? ''} {o.client.user.username ? `@${o.client.user.username}` : ''} · {o.locale.toUpperCase()}
{o.client.agency ? ` · ${t('admin.order.agency')}: ${o.client.agency.name}` : ''}
</dd>
<dt className="bc-muted">{t('admin.order.provider')}</dt>
<dd style={{ margin: 0 }}>
{o.provider?.name ?? '—'}
{o.company ? ` · ${o.company.name}` : ''}
</dd>
<dt className="bc-muted">{t('admin.order.price')}</dt>
<dd style={{ margin: 0 }}>
{amd(o.basePrice)}
{o.discountAmount > 0 ? ` − ${amd(o.discountAmount)} (Barev+ ${o.discountPct}%)` : ''} = <b>{amd(o.totalPrice)}</b>
{o.cancelFee > 0 ? ` · ${t('admin.order.cancelFee')}: ${amd(o.cancelFee)}` : ''}
</dd>
<dt className="bc-muted">{t('admin.order.payment')}</dt>
<dd style={{ margin: 0 }}>
{o.paymentMethod ? (o.paymentMethod.name as Record<string, string>)[locale] || (o.paymentMethod.name as Record<string, string>).ru : '—'} · {t(`payment.${o.paymentStatus}`)}
{o.paidAt ? ` · ${dt(o.paidAt)}` : ''}
</dd>
</dl>
</Card>
{canAssign && <Candidates orderId={o.id} />}
<Card className="bc-stack">
<h3 className="bc-h3">{t('admin.order.actions')}</h3>
<div className="bc-row" style={{ flexWrap: 'wrap' }}>
{o.adminTransitions.map((s) => (
<Button key={s} size="sm" variant="light" disabled={transition.isPending} onClick={() => transition.mutate({ orderId: o.id, to: s, comment: comment || undefined })}>
→ {t(`status.${o.service.flow}.${s}`)}
</Button>
))}
{(o.providerId || pendingOffer) && !isFinal && (
<Button size="sm" variant="outline" disabled={unassign.isPending} onClick={async () => (await confirmDialog(t('admin.order.confirmUnassign'))) && unassign.mutate({ orderId: o.id })}>
{t('admin.order.unassign')}
</Button>
)}
{o.paymentStatus === 'UNPAID' && o.status !== 'CANCELLED' && (
<Button size="sm" variant="dark" disabled={markPaid.isPending} onClick={async () => (await confirmDialog(t('admin.order.confirmPaid', { amount: amd(o.totalPrice) }))) && markPaid.mutate({ orderId: o.id, paymentMethodId: o.paymentMethodId ?? methods.data?.[0]?.id })}>
{t('admin.order.markPaid')}
</Button>
)}
{o.canCancel && (
<Button size="sm" variant="danger" disabled={cancel.isPending} onClick={async () => (await confirmDialog(t('admin.order.confirmCancel'))) && cancel.mutate({ orderId: o.id, reason: comment || undefined })}>
{t('admin.order.cancel')}
</Button>
)}
</div>
<Field label={t('admin.order.comment')}>
<input className="bc-input" value={comment} onChange={(e) => setComment(e.target.value)} maxLength={500} />
</Field>
</Card>
{o.checklist.length > 0 && (
<Card className="bc-stack">
<h3 className="bc-h3">{t('admin.order.checklist')}</h3>
{o.checklist.map((c) => (
<div key={c.id} className="bc-row">
<Icon name={c.checkedAt ? 'check' : 'close'} size={16} style={{ color: c.checkedAt ? 'var(--bc-ok)' : 'var(--bc-ink-3)' }} />
<span>{t(`checklist.${c.code}`)}</span>
{c.fileId && <FileLink fileId={c.fileId} />}
</div>
))}
</Card>
)}
<Card>
<h3 className="bc-h3" style={{ marginBottom: 8 }}>
{t('admin.order.history')}
</h3>
{o.history.map((h) => (
<div key={h.id} className="history-item">
<span className="bc-muted">{dt(h.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
<span>
{h.event ? t(`admin.events.${h.event}`) : `${h.fromStatus ? t(`status.${o.service.flow}.${h.fromStatus}`) + ' → ' : ''}${t(`status.${o.service.flow}.${h.toStatus}`)}`}
<span className="bc-muted"> · {t(`admin.actors.${h.actorType}`)}</span>
{h.comment ? <div className="bc-small">{h.comment}</div> : null}
</span>
</div>
))}
{o.offers.length > 0 && (
<>
<h4 style={{ margin: '12px 0 4px' }}>{t('admin.order.offers')}</h4>
{o.offers.map((f) => (
<div key={f.id} className="history-item">
<span className="bc-muted">{dt(f.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
<span>
{f.provider?.name ?? f.company?.name} · {t(`admin.offerStatus.${f.status}`)}
</span>
</div>
))}
</>
)}
</Card>
</>
)}
</aside>
</div>
);
}
function Candidates({ orderId }: { orderId: string }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const cands = useQuery(trpc.admin.orders.candidates.queryOptions({ id: orderId }));
const [showAll, setShowAll] = useState(false);
const assign = useMutation(
trpc.admin.orders.assign.mutationOptions({
onSuccess: () => {
toast(t('admin.order.offered'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
if (!cands.data) return <Spinner />;
const [best, ...rest] = cands.data.ranked;
return (
<Card tone="perks" className="bc-stack">
<h3 className="bc-h3">{t('admin.order.assignTitle')}</h3>
{best ? (
<Button block disabled={assign.isPending} onClick={() => assign.mutate({ orderId, target: { kind: best.kind, id: best.id } })}>
{t('admin.order.assignTo', { name: best.name })}
{best.kind === 'company' ? ` (${t('admin.order.company')})` : ''}
</Button>
) : (
<p style={{ margin: 0 }}>{t('admin.order.noCandidates')}</p>
)}
{best && <Reasons reasons={best.reasons} />}
{rest.length > 0 && (
<button className="bc-btn bc-btn--ghost bc-btn--sm" onClick={() => setShowAll(!showAll)}>
{t(showAll ? 'admin.order.hideOthers' : 'admin.order.chooseManually', { count: rest.length })}
</button>
)}
{showAll &&
rest.map((c) => (
<div key={`${c.kind}-${c.id}`} className="candidate">
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>
{c.name}
{c.kind === 'company' ? ` · ${t('admin.order.company')}` : ''}
</div>
<div className="bc-small bc-muted">
{c.languages.map((l) => l.toUpperCase()).join(' ')} · {t('admin.order.load', { count: c.load })}
{c.rating ? ` · ★ ${c.rating}` : ''}
</div>
<Reasons reasons={c.reasons} />
</div>
<Button size="sm" variant="light" disabled={assign.isPending} onClick={() => assign.mutate({ orderId, target: { kind: c.kind, id: c.id } })}>
{t('admin.order.assign')}
</Button>
</div>
))}
{cands.data.busy.length > 0 && (
<div className="bc-small">
{t('admin.order.busy')}: {cands.data.busy.map((b) => b.name).join(', ')}
</div>
)}
</Card>
);
}
function Reasons({ reasons }: { reasons: string[] }) {
const { t } = useI18n();
if (!reasons.length) return null;
return (
<div className="bc-row" style={{ flexWrap: 'wrap', gap: 4 }}>
{reasons.map((r) => (
<Badge key={r} style={{ fontSize: 11 }}>
{t(`admin.reasons.${r}`)}
</Badge>
))}
</div>
);
}
function FileLink({ fileId }: { fileId: string }) {
const { t } = useI18n();
const trpc = useTRPC();
const [enabled, setEnabled] = useState(false);
const url = useQuery({ ...trpc.admin.orders.fileUrl.queryOptions({ fileId }), enabled });
useEffect(() => {
if (url.data) window.open(url.data.url, '_blank', 'noopener');
}, [url.data]);
return (
<button className="bc-btn bc-btn--ghost bc-btn--sm" onClick={() => setEnabled(true)}>
<Icon name="camera" size={16} /> {t('admin.order.photo')}
</button>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { LOCALES } from '@barev/i18n';
import { Chip } from '@barev/ui';
import { useI18n } from '@barev/web';
import { useEffect, useState, type ReactNode } from 'react';
/** Мультивыбор чипами (языки, районы, роли) */
export function MultiChips<T extends string>({ value, options, onChange, label }: { value: T[]; options: { value: T; label: ReactNode }[]; onChange: (v: T[]) => void; label?: string }) {
return (
<div className="bc-chips" role="group" aria-label={label}>
{options.map((o) => {
const on = value.includes(o.value);
return (
<Chip key={o.value} pressed={on} onClick={() => onChange(on ? value.filter((v) => v !== o.value) : [...value, o.value])} style={{ minHeight: 36, padding: '4px 12px' }}>
{o.label}
</Chip>
);
})}
</div>
);
}
export const LOCALE_OPTIONS = LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }));
export type ScheduleRow = { weekday: number; startMinute: number; endMinute: number };
const toHm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const fromHm = (s: string) => {
const [h, m] = s.split(':').map(Number);
return (h ?? 0) * 60 + (m ?? 0);
};
/** Недельный график исполнителя */
export function ScheduleEditor({ value, onChange }: { value: ScheduleRow[]; onChange: (v: ScheduleRow[]) => void }) {
const { t } = useI18n();
return (
<div className="schedule">
{[0, 1, 2, 3, 4, 5, 6].map((wd) => {
const row = value.find((r) => r.weekday === wd);
const update = (patch: Partial<ScheduleRow>) => onChange(value.map((r) => (r.weekday === wd ? { ...r, ...patch } : r)));
return (
<div key={wd} style={{ display: 'contents' }}>
<span style={{ fontWeight: 600 }}>{t(`admin.weekdays.${wd}`)}</span>
<input className="bc-input" type="time" value={row ? toHm(row.startMinute) : ''} disabled={!row} onChange={(e) => update({ startMinute: fromHm(e.target.value) })} aria-label={t('admin.schedule.from')} />
<input className="bc-input" type="time" value={row ? toHm(row.endMinute) : ''} disabled={!row} onChange={(e) => update({ endMinute: fromHm(e.target.value) })} aria-label={t('admin.schedule.to')} />
<input
type="checkbox"
checked={!!row}
aria-label={t('admin.schedule.works')}
onChange={(e) => onChange(e.target.checked ? [...value, { weekday: wd, startMinute: 540, endMinute: 1200 }] : value.filter((r) => r.weekday !== wd))}
style={{ width: 22, height: 22 }}
/>
</div>
);
})}
</div>
);
}
/** Локальная копия значения, сбрасывается при смене исходного */
export function useDraft<T>(source: T | undefined) {
const [draft, setDraft] = useState<T | undefined>(source);
useEffect(() => setDraft(source), [source]);
return [draft, setDraft] as const;
}
export function Copyable({ text }: { text: string }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
return (
<span className="bc-row" style={{ gap: 6 }}>
<code className="mono" style={{ wordBreak: 'break-all' }}>
{text}
</code>
<button
type="button"
className="bc-btn bc-btn--ghost bc-btn--sm"
onClick={() => {
void navigator.clipboard?.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? t('admin.copied') : t('admin.copy')}
</button>
</span>
);
}
+16
View File
@@ -0,0 +1,16 @@
import '@barev/ui/styles.css';
import './admin.css';
import { ApiProvider, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ApiProvider withCredentials>
<ToastProvider>
<App />
</ToastProvider>
</ApiProvider>
</StrictMode>,
);
+119
View File
@@ -0,0 +1,119 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Field } from '@barev/ui';
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { Copyable } from '../components/form.tsx';
interface Draft {
id?: string;
code: string;
name: string;
commissionPct: number;
contact: string;
active: boolean;
}
/** ADM-5: агентства — код для deep-link/QR и % вознаграждения. Полный отчёт ADM-8 — Этап 2. */
export function Agencies({ isOwner }: { isOwner: boolean }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.admin.agencies.list.queryOptions());
const config = useQuery(trpc.admin.dictionaries.config.queryOptions());
const settings = useQuery(trpc.admin.settings.get.queryOptions());
const [d, setD] = useState<Draft | null>(null);
const onDone = () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
setD(null);
};
const create = useMutation(trpc.admin.agencies.create.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
const update = useMutation(trpc.admin.agencies.update.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.nav.agencies')}</h1>
{isOwner && (
<Button size="sm" onClick={() => setD({ code: '', name: '', commissionPct: settings.data?.['agency.defaultCommissionPct'] ?? 10, contact: '', active: true })}>
{t('admin.agencies.add')}
</Button>
)}
</div>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('admin.agencies.hint', { months: settings.data?.['agency.attributionMonths'] ?? 12 })}
</p>
{d && (
<Card className="bc-stack">
<div className="form-grid">
<Field label={t('admin.agencies.code')}>
<input className="bc-input mono" value={d.code} onChange={(e) => setD({ ...d, code: e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '') })} maxLength={32} />
</Field>
<Field label={t('admin.agencies.name')}>
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} />
</Field>
<Field label={t('admin.agencies.commission')}>
<input className="bc-input" type="number" min={0} max={100} value={d.commissionPct} onChange={(e) => setD({ ...d, commissionPct: Number(e.target.value) })} />
</Field>
<Field label={t('admin.agencies.contact')}>
<input className="bc-input" value={d.contact} onChange={(e) => setD({ ...d, contact: e.target.value })} />
</Field>
<label className="bc-row">
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
</label>
</div>
<div className="bc-row">
<Button
disabled={d.code.length < 2 || !d.name}
onClick={() => {
const payload = { ...d, contact: d.contact || null };
if (d.id) update.mutate({ ...payload, id: d.id });
else create.mutate(payload);
}}
>
{t('admin.save')}
</Button>
<Button variant="light" onClick={() => setD(null)}>
{t('common.cancel')}
</Button>
</div>
</Card>
)}
<table className="grid">
<thead>
<tr>
<th>{t('admin.agencies.name')}</th>
<th>{t('admin.agencies.code')}</th>
<th>{t('admin.agencies.commission')}</th>
<th>{t('admin.agencies.leads')}</th>
<th>{t('admin.agencies.links')}</th>
</tr>
</thead>
<tbody>
{list.data?.map((a) => (
<tr key={a.id} className={isOwner ? 'clickable' : undefined} style={{ opacity: a.active ? 1 : 0.5 }} onClick={() => isOwner && setD({ id: a.id, code: a.code, name: a.name, commissionPct: a.commissionPct, contact: a.contact ?? '', active: a.active })}>
<td>
<b>{a.name}</b>
{a.contact && <div className="bc-small bc-muted">{a.contact}</div>}
</td>
<td className="mono">{a.code}</td>
<td>{a.commissionPct}%</td>
<td>{a.leads}</td>
<td onClick={(e) => e.stopPropagation()} className="bc-stack" style={{ gap: 4 }}>
{config.data && (
<>
<span className="bc-small">{t('admin.agencies.bot')}</span>
<Copyable text={`https://t.me/${config.data.clientBot}?start=a-${a.code}`} />
<span className="bc-small">{t('admin.agencies.landing')}</span>
<Copyable text={`${config.data.landingUrl}/?a=${a.code}`} />
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Badge, Button } from '@barev/ui';
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
/** ADM-6 (базово): клиенты, источник, статус Barev+ */
export function Clients({ isOwner }: { isOwner: boolean }) {
const { t, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [search, setSearch] = useState('');
const list = useQuery(trpc.admin.clients.list.queryOptions({ search: search.trim() || undefined }));
const grant = useMutation(
trpc.admin.clients.grantBarevPlus.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.nav.clients')}</h1>
<input className="bc-input" type="search" placeholder={t('admin.kanban.search')} value={search} onChange={(e) => setSearch(e.target.value)} style={{ width: 280 }} />
</div>
<table className="grid">
<thead>
<tr>
<th>{t('admin.clients.name')}</th>
<th>{t('admin.clients.language')}</th>
<th>{t('admin.clients.source')}</th>
<th>{t('admin.clients.membership')}</th>
<th>{t('admin.clients.orders')}</th>
<th>{t('admin.clients.since')}</th>
{isOwner && <th />}
</tr>
</thead>
<tbody>
{list.data?.map((c) => (
<tr key={c.id}>
<td>
<b>
{c.user.firstName} {c.user.lastName ?? ''}
</b>
{c.user.username && <div className="bc-small bc-muted">@{c.user.username}</div>}
<a href={`#/concierge?client=${c.id}`} className="bc-small" style={{ fontWeight: 600 }}>
{t('admin.concierge.write')}
</a>
{!c.consented && <Badge style={{ background: 'var(--bc-docs)' }}>{t('admin.clients.noConsent')}</Badge>}
</td>
<td>{c.user.locale.toUpperCase()}</td>
<td>
{c.agency ? `${c.agency.name} (${c.agency.code})` : t(`admin.clients.source_${c.source}`)}
{c.utm && <div className="bc-small bc-muted mono">{Object.entries(c.utm as Record<string, string>).map(([k, v]) => `${k.replace('utm_', '')}=${v}`).join(' ')}</div>}
</td>
<td>
{c.barevPlusUntil ? (
<Badge style={{ background: c.plan === 'ELITE' ? 'var(--bc-perks)' : 'var(--bc-pink)' }}>
{c.plan === 'ELITE' ? 'Elite' : 'Barev+'} · {dt(c.barevPlusUntil, { day: 'numeric', month: 'short' })}
</Badge>
) : (
'Barev'
)}
{c.segment && <div className="bc-small bc-muted">{t(`admin.clients.segment_${c.segment}`)}</div>}
</td>
<td>{c.orders}</td>
<td className="bc-small">{dt(c.createdAt, { dateStyle: 'medium' })}</td>
{isOwner && (
<td>
<div className="bc-chips">
<Button size="sm" variant="light" onClick={async () => (await confirmDialog(t('admin.clients.grantConfirm'))) && grant.mutate({ clientId: c.id, days: 30, plan: 'BAREV_PLUS' })}>
{t('admin.clients.grant')}
</Button>
<Button size="sm" variant="light" onClick={async () => (await confirmDialog(t('admin.clients.grantEliteConfirm'))) && grant.mutate({ clientId: c.id, days: 30, plan: 'ELITE' })}>
{t('admin.clients.grantElite')}
</Button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { PROVIDER_ROLES, type ProviderRole } from '@barev/core';
import { Button, Card, Field } from '@barev/ui';
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { Copyable, MultiChips } from '../components/form.tsx';
interface Draft {
id?: string;
name: string;
roles: ProviderRole[];
rateType: 'FIXED' | 'PERCENT';
rateValue: number;
contactPhone: string;
active: boolean;
}
/** PRO-7, PRO-8: компании-партнёры, их диспетчеры и договорная ставка */
export function Companies() {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.admin.companies.list.queryOptions());
const config = useQuery(trpc.admin.dictionaries.config.queryOptions());
const [d, setD] = useState<Draft | null>(null);
const onDone = () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
setD(null);
};
const create = useMutation(trpc.admin.companies.create.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
const update = useMutation(trpc.admin.companies.update.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
const removeDispatcher = useMutation(trpc.admin.companies.removeDispatcher.mutationOptions({ onSuccess: () => qc.invalidateQueries() }));
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.nav.companies')}</h1>
<Button size="sm" onClick={() => setD({ name: '', roles: ['CLEANER'], rateType: 'PERCENT', rateValue: 65, contactPhone: '', active: true })}>
{t('admin.companies.add')}
</Button>
</div>
{d && (
<Card className="bc-stack">
<div className="form-grid">
<Field label={t('admin.companies.name')}>
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} />
</Field>
<Field label={t('admin.companies.phone')}>
<input className="bc-input" value={d.contactPhone} onChange={(e) => setD({ ...d, contactPhone: e.target.value })} />
</Field>
<div className="full">
<Field label={t('admin.companies.roles')}>
<MultiChips value={d.roles} options={PROVIDER_ROLES.map((r) => ({ value: r, label: t(`roles.${r}`) }))} onChange={(roles) => setD({ ...d, roles })} />
</Field>
</div>
<Field label={t('admin.providers.rateType')}>
<select className="bc-input" value={d.rateType} onChange={(e) => setD({ ...d, rateType: e.target.value as Draft['rateType'] })}>
<option value="PERCENT">{t('admin.rate.PERCENT')}</option>
<option value="FIXED">{t('admin.rate.FIXED')}</option>
</select>
</Field>
<Field label={d.rateType === 'PERCENT' ? t('admin.providers.ratePercent') : t('admin.providers.rateFixed')}>
<input className="bc-input" type="number" min={0} value={d.rateValue} onChange={(e) => setD({ ...d, rateValue: Number(e.target.value) })} />
</Field>
<label className="bc-row">
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
</label>
</div>
<div className="bc-row">
<Button
disabled={!d.name || !d.roles.length}
onClick={() => {
const payload = { ...d, contactPhone: d.contactPhone || null };
if (d.id) update.mutate({ ...payload, id: d.id });
else create.mutate(payload);
}}
>
{t('admin.save')}
</Button>
<Button variant="light" onClick={() => setD(null)}>
{t('common.cancel')}
</Button>
</div>
</Card>
)}
{list.data?.map((c) => (
<Card key={c.id} className="bc-stack" style={{ opacity: c.active ? 1 : 0.6 }}>
<div className="bc-between">
<div>
<h2 className="bc-h3">{c.name}</h2>
<div className="bc-small bc-muted">
{c.roles.map((r) => t(`roles.${r}`)).join(', ')} · {c.rateType === 'PERCENT' ? `${c.rateValue}%` : `${c.rateValue} ֏`}
</div>
</div>
<Button size="sm" variant="light" onClick={() => setD({ id: c.id, name: c.name, roles: c.roles, rateType: c.rateType, rateValue: c.rateValue, contactPhone: c.contactPhone ?? '', active: c.active })}>
{t('admin.edit')}
</Button>
</div>
<div className="bc-small">
<b>{t('admin.companies.employees')}:</b> {c.employees.map((e) => e.name).join(', ') || '—'}
</div>
<div className="bc-small">
<b>{t('admin.companies.dispatchers')}:</b>{' '}
{c.dispatchers.length
? c.dispatchers.map((x) => (
<span key={x.id} className="bc-row" style={{ display: 'inline-flex', marginRight: 10 }}>
{x.user.firstName ?? x.user.username}
<button className="bc-btn bc-btn--ghost bc-btn--sm" onClick={() => removeDispatcher.mutate({ id: x.id })}>
×
</button>
</span>
))
: '—'}
</div>
{config.data && (
<div className="bc-small">
<b>{t('admin.companies.dispatcherInvite')}:</b> <Copyable text={`https://t.me/${config.data.proBot}?start=${c.inviteCode}`} />
</div>
)}
</Card>
))}
</>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Chip } from '@barev/ui';
import { errorText, navigate, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useRef, useState } from 'react';
type Peer = { clientId: string } | { providerId: string };
const keyOf = (p: Peer) => ('clientId' in p ? `c:${p.clientId}` : `p:${p.providerId}`);
/**
* Переписка: клиенты пишут клиентскому боту, исполнители — рабочему; оператор отвечает здесь,
* ответ приходит в тот же чат с ботом. Открыть диалог с кем угодно: #/concierge?client=… или ?provider=…
*/
export function Concierge() {
const { t, dt } = useI18n();
const trpc = useTRPC();
const { query } = useLocation();
const list = useQuery({ ...trpc.admin.concierge.list.queryOptions(), refetchInterval: 10_000 });
const [tab, setTab] = useState<'all' | 'clients' | 'providers'>('all');
const fromUrl: Peer | null = query.get('client') ? { clientId: query.get('client')! } : query.get('provider') ? { providerId: query.get('provider')! } : null;
const items = (list.data ?? []).filter((c) => (tab === 'all' ? true : tab === 'clients' ? !!c.clientId : !!c.providerId));
const first = items[0];
const selected: Peer | null = fromUrl ?? (first ? (first.clientId ? { clientId: first.clientId } : { providerId: first.providerId! }) : null);
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.nav.concierge')}</h1>
<div className="bc-chips">
{(['all', 'clients', 'providers'] as const).map((x) => (
<Chip key={x} pressed={tab === x} onClick={() => setTab(x)}>
{t(`admin.concierge.tab_${x}`)}
</Chip>
))}
</div>
</div>
{!selected ? (
<p className="bc-muted">{t('admin.concierge.empty')}</p>
) : (
<div className="chat">
<ul className="chat__list">
{items.map((c) => (
<li key={c.key}>
<button className="chat__item" aria-current={c.key === keyOf(selected)} onClick={() => navigate(`/concierge?${c.clientId ? `client=${c.clientId}` : `provider=${c.providerId}`}`)}>
<span className="bc-between">
<b>{c.name}</b>
{c.unread > 0 && <span className="nav-count">{c.unread}</span>}
</span>
<span className="bc-small bc-muted chat__preview">
{c.last.author === 'OPERATOR' ? `${t('admin.concierge.you')}: ` : ''}
{c.last.text}
</span>
<span className="bc-small bc-muted">
{dt(c.last.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
{' · '}
{c.providerRole ? `${t('admin.concierge.provider')} · ${t(`roles.${c.providerRole}`)}` : c.segment ? t(`admin.clients.segment_${c.segment}`) : t('admin.concierge.client')}
</span>
</button>
</li>
))}
</ul>
<Conversation key={keyOf(selected)} peer={selected} />
</div>
)}
</>
);
}
function Conversation({ peer }: { peer: Peer }) {
const { t, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [text, setText] = useState('');
const end = useRef<HTMLDivElement>(null);
const data = useQuery({ ...trpc.admin.concierge.get.queryOptions(peer), refetchInterval: 10_000 });
const reply = useMutation(
trpc.admin.concierge.reply.mutationOptions({
onSuccess: () => {
setText('');
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const send = () => text.trim() && reply.mutate({ ...peer, text: text.trim() });
useEffect(() => {
end.current?.scrollIntoView({ block: 'end' });
}, [data.data?.messages.length]);
// открыли диалог — счётчики непрочитанных обновятся
useEffect(() => {
if (data.data) void qc.invalidateQueries({ queryKey: trpc.admin.concierge.list.queryKey() });
}, [data.data, qc, trpc]);
const p = data.data?.peer;
return (
<section className="chat__body">
<div className="chat__head">
<b>{p?.name}</b>
{p?.username && (
<a href={`https://t.me/${p.username}`} target="_blank" rel="noopener" className="bc-small">
@{p.username}
</a>
)}
{p && <span className="bc-small bc-muted">{t('admin.concierge.replyLang', { lang: p.locale.toUpperCase() })}</span>}
</div>
<div className="chat__messages">
{data.data?.messages.length === 0 && <p className="bc-muted bc-small">{t('admin.concierge.startHint')}</p>}
{data.data?.messages.map((m) => (
<div key={m.id} className="msg" data-author={m.author === 'OPERATOR' ? 'OPERATOR' : 'PEER'}>
<div>{m.text}</div>
<span className="bc-small">{dt(m.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
</div>
))}
<div ref={end} />
</div>
{p && !p.reachable ? (
<p className="bc-small bc-muted" style={{ padding: 12, margin: 0 }}>
{t('errors.PROVIDER_NOT_LINKED')}
</p>
) : (
<form
className="chat__form"
onSubmit={(e) => {
e.preventDefault();
send();
}}
>
<textarea
className="bc-input"
value={text}
maxLength={4000}
placeholder={t('admin.concierge.placeholder')}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) send();
}}
/>
<Button type="submit" disabled={reply.isPending || !text.trim()}>
{t('admin.concierge.send')}
</Button>
</form>
)}
</section>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useQuery } from '@tanstack/react-query';
import { CATALOG, CATEGORY_CODES } from '@barev/core';
import { Badge, Card, Chip } from '@barev/ui';
import { useI18n, useLocation, navigate, useTRPC } from '@barev/web';
import { useEffect, useRef, useState } from 'react';
import { OrderDrawer } from '../components/OrderDrawer.tsx';
const COLUMNS = ['new', 'assigned', 'in_progress', 'done'] as const;
const CAT_COLOR: Record<string, string> = { HOME: 'var(--bc-home)', DOCS: 'var(--bc-docs)', LEARNING: 'var(--bc-learn)', PERKS: 'var(--bc-perks)' };
/** ADM-1, ADM-2: сводка и канбан заказов с фильтрами */
export function Kanban() {
const { t, amd, dt } = useI18n();
const trpc = useTRPC();
const { query } = useLocation();
const openId = query.get('order');
const [category, setCategory] = useState('');
const [serviceCode, setServiceCode] = useState('');
const [date, setDate] = useState('');
const [search, setSearch] = useState('');
const [showCancelled, setShowCancelled] = useState(false);
const filter = { category: category || undefined, serviceCode: serviceCode || undefined, date: date || undefined, search: search.trim() || undefined, includeCancelled: showCancelled };
const board = useQuery({ ...trpc.admin.orders.kanban.queryOptions(filter), refetchInterval: 10_000 });
const summary = useQuery({ ...trpc.admin.orders.summary.queryOptions(), refetchInterval: 15_000 });
// ORD-5: новый заказ — звуковой/визуальный сигнал оператору
const known = useRef<Set<string> | null>(null);
const [fresh, setFresh] = useState<Set<string>>(new Set());
useEffect(() => {
if (!board.data) return;
const ids = new Set(board.data.new.map((o) => o.id));
if (known.current) {
const added = [...ids].filter((id) => !known.current!.has(id));
if (added.length) {
setFresh(new Set(added));
document.title = `● ${t('admin.kanban.newOrderTitle')}`;
setTimeout(() => (document.title = 'Barev Club · Admin'), 8000);
}
}
known.current = ids;
}, [board.data, t]);
const cols = showCancelled ? [...COLUMNS, 'cancelled' as const] : COLUMNS;
const services = CATALOG.filter((s) => !category || s.category === category);
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.kanban.title')}</h1>
</div>
<div className="summary">
<Card tone="pink">
<div className="bc-small">{t('admin.summary.ordersToday')}</div>
<div className="summary__value">{summary.data?.ordersToday ?? '—'}</div>
</Card>
<Card tone={summary.data?.unassigned ? 'perks' : 'white'}>
<div className="bc-small">{t('admin.summary.unassigned')}</div>
<div className="summary__value">{summary.data?.unassigned ?? '—'}</div>
</Card>
<Card tone="home">
<div className="bc-small">{t('admin.summary.revenueToday')}</div>
<div className="summary__value">{summary.data ? amd(summary.data.revenueToday) : '—'}</div>
</Card>
<Card tone="blue">
<div className="bc-small">{t('admin.summary.freeProviders')}</div>
<div className="summary__value">
{summary.data ? `${summary.data.freeProviders} / ${summary.data.providersOnShift}` : '—'}
</div>
</Card>
</div>
<div className="toolbar">
<select className="bc-input" value={category} onChange={(e) => (setCategory(e.target.value), setServiceCode(''))} aria-label={t('admin.kanban.category')}>
<option value="">{t('admin.kanban.allCategories')}</option>
{CATEGORY_CODES.filter((c) => c !== 'PERKS').map((c) => (
<option key={c} value={c}>
{t(`categories.${c}.name`)}
</option>
))}
</select>
<select className="bc-input" value={serviceCode} onChange={(e) => setServiceCode(e.target.value)} aria-label={t('admin.kanban.service')}>
<option value="">{t('admin.kanban.allServices')}</option>
{services.map((s) => (
<option key={s.code} value={s.code}>
{t(`services.${s.code}.name`)}
</option>
))}
</select>
<input className="bc-input" type="date" value={date} onChange={(e) => setDate(e.target.value)} aria-label={t('admin.kanban.date')} />
<input className="bc-input" type="search" placeholder={t('admin.kanban.search')} value={search} onChange={(e) => setSearch(e.target.value)} style={{ minWidth: 240 }} />
<Chip pressed={showCancelled} onClick={() => setShowCancelled(!showCancelled)}>
{t('admin.kanban.showCancelled')}
</Chip>
</div>
<div className="kanban" style={{ ['--cols' as string]: cols.length }}>
{cols.map((col) => {
const items = board.data?.[col] ?? [];
return (
<section key={col} className="column" aria-label={t(`admin.kanban.col.${col}`)}>
<div className="column__head">
<span>{t(`admin.kanban.col.${col}`)}</span>
<Badge>{items.length}</Badge>
</div>
{items.map((o) => (
<button key={o.id} className="ocard" onClick={() => navigate(`/?order=${o.id}`)} style={fresh.has(o.id) ? { borderColor: 'var(--bc-accent)' } : undefined}>
<div className="bc-between">
<span className="ocard__stripe" style={{ background: CAT_COLOR[o.category] }} />
<span className="bc-small bc-muted">#{o.number}</span>
</div>
<div style={{ fontWeight: 700 }}>{t(`services.${o.serviceCode}.name`)}</div>
<div className="bc-small">{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</div>
<div className="bc-small bc-muted">
{o.clientName} · {o.clientLocale.toUpperCase()}
{o.districtCode ? ` · ${t(`districts.${o.districtCode}`)}` : ''}
</div>
<div className="bc-between">
<span className="bc-small" style={{ fontWeight: 600 }}>
{amd(o.totalPrice)}
</span>
<span className="bc-small">{t(`payment.${o.paymentStatus}`)}</span>
</div>
{o.pending ? (
<Badge style={{ background: o.pending.kind === 'company' ? 'var(--bc-docs)' : 'var(--bc-perks)' }}>
{o.pending.kind === 'company' ? t('admin.kanban.toCompany', { name: o.pending.name }) : t('admin.kanban.awaiting', { name: o.pending.name })}
</Badge>
) : o.providerName ? (
<Badge style={{ background: 'var(--bc-bg)' }}>
{o.providerName}
{o.companyName ? ` · ${o.companyName}` : ''}
</Badge>
) : null}
<span className="bc-small bc-muted">{t(`status.${flowOf(o.serviceCode)}.${o.status}`)}</span>
</button>
))}
</section>
);
})}
</div>
{openId && <OrderDrawer id={openId} onClose={() => navigate('/')} />}
</>
);
}
export const flowOf = (serviceCode: string) => CATALOG.find((s) => s.code === serviceCode)?.flow ?? 'CLEANING';
+337
View File
@@ -0,0 +1,337 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatAmd } from '@barev/i18n';
import { Badge, Button, Chip } from '@barev/ui';
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { Fragment, useState } from 'react';
const STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
type Status = (typeof STATUSES)[number];
const STATUS_TONE: Record<Status, string> = {
NEW: 'var(--bc-perks)',
IN_WORK: 'var(--bc-pink)',
DONE: 'var(--bc-home)',
CANCELLED: 'var(--bc-line)',
};
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;
}
/** Заявки из квиза на главной (barevclub.com): появляются после первого шага, дальше дописываются ответы */
export function Leads() {
const { t, dt, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [status, setStatus] = useState<Status | undefined>();
const list = useQuery({ ...trpc.admin.leads.list.queryOptions({ status }), refetchInterval: 15_000 });
const update = useMutation(
trpc.admin.leads.update.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
/** Что человек отметил в конструкторе — одной строкой */
function describe(o: Options) {
const parts: string[] = [];
if (o.airport && o.airport !== 'none') parts.push(`${t('landing.quiz.airport')}: ${t(`landing.quiz.airport_${o.airport}`)}`);
if (o.stay && o.stay !== 'none') {
const budget = o.stayBudgetFrom || o.stayBudgetTo ? ` (${t('admin.leads.budget', { from: o.stayBudgetFrom ? formatAmd(locale, o.stayBudgetFrom) : '…', to: o.stayBudgetTo ? formatAmd(locale, o.stayBudgetTo) : '…' })})` : '';
parts.push(`${t('landing.quiz.stay')}: ${t(`landing.quiz.stay_${o.stay}`)}${budget}`);
}
for (const k of YES_NO) if (o[k]) parts.push(t(`landing.quiz.${k}`));
return parts;
}
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.leads.title')}</h1>
<div className="bc-chips">
<Chip pressed={!status} onClick={() => setStatus(undefined)}>
{t('admin.leads.all')}
</Chip>
{STATUSES.map((s) => (
<Chip key={s} pressed={status === s} onClick={() => setStatus(s)}>
{t(`admin.leads.status_${s}`)}
</Chip>
))}
</div>
</div>
{list.data?.length === 0 ? (
<p className="bc-muted">{t('admin.leads.empty')}</p>
) : (
<table className="grid">
<thead>
<tr>
<th>{t('admin.leads.number')}</th>
<th>{t('admin.leads.contact')}</th>
<th>{t('admin.leads.plan')}</th>
<th>{t('admin.leads.package')}</th>
<th>{t('admin.leads.status')}</th>
<th>{t('admin.leads.note')}</th>
</tr>
</thead>
<tbody>
{list.data?.map((r) => {
const href = r.contactType === 'TELEGRAM' ? `https://t.me/${r.contact.slice(1)}` : `tel:${r.contact}`;
const opts = r.package === 'CUSTOM' && r.options ? describe(r.options as Options) : [];
return (
<Fragment key={r.id}>
<tr>
<td>
<b>{r.number}</b>
<div className="bc-small bc-muted">{t('admin.leads.created', { date: dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) })}</div>
</td>
<td>
<b>{r.name}</b>
<div>
<a href={href} target="_blank" rel="noopener">
{r.contact}
</a>
</div>
<div className="bc-small bc-muted">
{r.locale.toUpperCase()}
{r.agency ? ` · ${r.agency}` : ''}
{r.client ? ` · ${t('admin.leads.inApp')}` : ''}
</div>
</td>
<td className="bc-small">
{r.moved ? (
<b>{t('admin.leads.moved')}</b>
) : r.moveWhen === 'date' && r.moveDate ? (
dt(r.moveDate, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
) : r.moveWhen ? (
t(`landing.quiz.when_${r.moveWhen}`)
) : (
'—'
)}
</td>
<td className="bc-small">
{r.package ? <b>{t(`landing.quiz.package_${r.package}`)}</b> : '—'}
{opts.length > 0 && (
<ul style={{ margin: '4px 0 0', paddingLeft: 16 }}>
{opts.map((o) => (
<li key={o}>{o}</li>
))}
</ul>
)}
{!r.submittedAt && <div className="bc-muted">{t('admin.leads.unfinished')}</div>}
</td>
<td>
<Badge style={{ background: STATUS_TONE[r.status] }}>{t(`admin.leads.status_${r.status}`)}</Badge>
<div className="bc-chips" style={{ marginTop: 8 }}>
{STATUSES.filter((s) => s !== r.status).map((s) => (
<Button key={s} size="sm" variant="light" onClick={() => update.mutate({ id: r.id, status: s })}>
{t(`admin.leads.status_${s}`)}
</Button>
))}
</div>
</td>
<td style={{ minWidth: 200 }}>
<NoteEditor initial={r.adminNote ?? ''} onSave={(adminNote) => update.mutate({ id: r.id, adminNote })} />
</td>
</tr>
{r.package && !r.moved && (
<tr>
<td colSpan={6} style={{ background: 'var(--bc-bg)' }}>
<LeadPlan lead={r} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
)}
</>
);
}
function NoteEditor({ initial, onSave }: { initial: string; onSave: (v: string) => void }) {
const { t } = useI18n();
const [value, setValue] = useState(initial);
return (
<div className="bc-stack" style={{ gap: 6 }}>
<textarea className="bc-input" style={{ minHeight: 60 }} value={value} maxLength={1000} placeholder={t('admin.webRequests.notePlaceholder')} onChange={(e) => setValue(e.target.value)} />
{value !== initial && (
<Button size="sm" variant="outline" onClick={() => onSave(value)}>
{t('admin.webRequests.saveNote')}
</Button>
)}
</div>
);
}
const STEP_STATUSES = ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED'] as const;
type Names = Record<string, { name: Record<string, string>; hint: Record<string, string> | null }>;
const nameOf = (names: Names | undefined, kind: 'STEP' | 'DOC', it: { code: string; title: string | null }, locale: string) => {
const n = names?.[`${kind}:${it.code}`]?.name;
return it.title || n?.[locale] || n?.ru || it.code;
};
function useSaved() {
const { t } = useI18n();
const qc = useQueryClient();
const toast = useToast();
return {
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
},
onError: (e: unknown) => toast(errorText(t, e), 'error'),
};
}
/** План клиента: шаги и документы — копия общего списка, которую можно менять только для этого клиента */
function LeadPlan({ lead }: { lead: { id: string; client: unknown; steps: { id: string; code: string; title: string | null; status: (typeof STEP_STATUSES)[number]; note: string | null }[]; docs: { id: string; code: string; title: string | null; checked: boolean }[] } }) {
const { t, locale } = useI18n();
const trpc = useTRPC();
const saved = useSaved();
const names = useQuery(trpc.admin.leads.names.queryOptions());
const templates = useQuery(trpc.admin.leads.templates.queryOptions());
const addStep = useMutation(trpc.admin.leads.addStep.mutationOptions(saved));
const addDoc = useMutation(trpc.admin.leads.addDoc.mutationOptions(saved));
const updateDoc = useMutation(trpc.admin.leads.updateDoc.mutationOptions(saved));
const removeDoc = useMutation(trpc.admin.leads.removeDoc.mutationOptions(saved));
const free = (kind: 'STEP' | 'DOC', taken: { code: string }[]) => (templates.data ?? []).filter((x) => x.kind === kind && x.active && !taken.some((y) => y.code === x.code));
return (
<div className="lead-plan">
<div className="bc-small bc-muted">{lead.client ? t('admin.leads.stepsInApp') : t('admin.leads.stepsNoApp')}</div>
<div className="lead-steps">
{lead.steps.map((s) => (
<StepEditor key={s.id} step={s} name={nameOf(names.data, 'STEP', s, locale)} />
))}
</div>
<AddItem label={t('admin.leads.addStep')} options={free('STEP', lead.steps).map((x) => ({ code: x.code, name: (x.name as Record<string, string>)[locale] || (x.name as Record<string, string>).ru! }))} onAdd={(v) => addStep.mutate({ leadId: lead.id, ...v })} />
<div className="bc-small bc-muted" style={{ marginTop: 10 }}>
{t('admin.leads.docsTitle', { done: lead.docs.filter((d) => d.checked).length, total: lead.docs.length })}
</div>
<div className="lead-docs">
{lead.docs.map((d) => (
<div key={d.id} className="lead-doc">
<input type="checkbox" checked={d.checked} onChange={(e) => updateDoc.mutate({ docId: d.id, checked: e.target.checked })} aria-label={nameOf(names.data, 'DOC', d, locale)} />
<InlineTitle value={nameOf(names.data, 'DOC', d, locale)} onSave={(title) => updateDoc.mutate({ docId: d.id, title })} />
<button className="lead-x" title={t('admin.leads.remove')} onClick={() => removeDoc.mutate({ docId: d.id })}>
×
</button>
</div>
))}
</div>
<AddItem label={t('admin.leads.addDoc')} options={free('DOC', lead.docs).map((x) => ({ code: x.code, name: (x.name as Record<string, string>)[locale] || (x.name as Record<string, string>).ru! }))} onAdd={(v) => addDoc.mutate({ leadId: lead.id, ...v })} />
</div>
);
}
/** Добавить пункт: из общего списка или свой текст */
function AddItem({ label, options, onAdd }: { label: string; options: { code: string; name: string }[]; onAdd: (v: { code?: string; title?: string }) => void }) {
const { t } = useI18n();
const [value, setValue] = useState('');
const [title, setTitle] = useState('');
return (
<div className="bc-row" style={{ flexWrap: 'wrap', gap: 6 }}>
<select className="bc-input" style={{ minHeight: 36, width: 'auto' }} value={value} onChange={(e) => setValue(e.target.value)}>
<option value="">{label}…</option>
{options.map((o) => (
<option key={o.code} value={o.code}>
{o.name}
</option>
))}
<option value="__custom">{t('admin.leads.custom')}</option>
</select>
{value === '__custom' && <input className="bc-input" style={{ minHeight: 36, width: 260 }} value={title} maxLength={120} placeholder={t('admin.leads.customPlaceholder')} onChange={(e) => setTitle(e.target.value)} />}
{value && (
<Button
size="sm"
variant="outline"
disabled={value === '__custom' && !title.trim()}
onClick={() => {
onAdd(value === '__custom' ? { title: title.trim() } : { code: value });
setValue('');
setTitle('');
}}
>
{t('admin.add')}
</Button>
)}
</div>
);
}
/** Название, которое правится по клику */
function InlineTitle({ value, onSave }: { value: string; onSave: (v: string) => void }) {
const [edit, setEdit] = useState<string | null>(null);
if (edit === null)
return (
<span className="lead-title" onClick={() => setEdit(value)} title="✎">
{value}
</span>
);
return (
<input
className="bc-input"
autoFocus
style={{ minHeight: 32, flex: 1 }}
value={edit}
maxLength={120}
onChange={(e) => setEdit(e.target.value)}
onBlur={() => {
if (edit.trim() && edit.trim() !== value) onSave(edit.trim());
setEdit(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') setEdit(null);
}}
/>
);
}
/** Шаг плана: название, статус и комментарий, который клиент увидит в приложении и в боте */
function StepEditor({ step, name }: { step: { id: string; code: string; title: string | null; status: (typeof STEP_STATUSES)[number]; note: string | null }; name: string }) {
const { t } = useI18n();
const trpc = useTRPC();
const saved = useSaved();
const [note, setNote] = useState(step.note ?? '');
const save = useMutation(trpc.admin.leads.updateStep.mutationOptions(saved));
const remove = useMutation(trpc.admin.leads.removeStep.mutationOptions(saved));
return (
<div className="lead-step">
<div className="bc-row" style={{ gap: 6 }}>
<InlineTitle value={name} onSave={(title) => save.mutate({ stepId: step.id, title })} />
<button className="lead-x" title={t('admin.leads.remove')} onClick={async () => (await confirmDialog(t('admin.leads.removeStepConfirm', { name }))) && remove.mutate({ stepId: step.id })}>
×
</button>
</div>
<select className="bc-input" value={step.status} onChange={(e) => save.mutate({ stepId: step.id, status: e.target.value as (typeof STEP_STATUSES)[number] })} style={{ minHeight: 36 }}>
{STEP_STATUSES.map((s) => (
<option key={s} value={s}>
{t(`client.move.status.${s}`)}
</option>
))}
</select>
<input className="bc-input" style={{ minHeight: 36 }} value={note} maxLength={500} placeholder={t('admin.leads.stepNote')} onChange={(e) => setNote(e.target.value)} />
{note !== (step.note ?? '') && (
<Button size="sm" variant="outline" onClick={() => save.mutate({ stepId: step.id, note })}>
{t('admin.webRequests.saveNote')}
</Button>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Field, Logo } from '@barev/ui';
import { errorText, useI18n, useTRPC } from '@barev/web';
import QRCode from 'qrcode';
import { useEffect, useState } from 'react';
/** Вход: пароль → (первый раз) настройка 2FA → код из приложения-аутентификатора */
export function Login({ stage }: { stage: 'password' | 'totp' | 'totp_setup' }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [error, setError] = useState<string | null>(null);
const [setup, setSetup] = useState<{ secret: string; qr: string } | null>(null);
const refresh = () => qc.invalidateQueries();
const login = useMutation(trpc.admin.auth.login.mutationOptions({ onSuccess: refresh, onError: (e) => setError(errorText(t, e)) }));
const totpSetup = useMutation(
trpc.admin.auth.totpSetup.mutationOptions({
onSuccess: async (r) => setSetup({ secret: r.secret, qr: await QRCode.toDataURL(r.uri, { margin: 1, width: 220 }) }),
onError: (e) => setError(errorText(t, e)),
}),
);
const verify = useMutation(trpc.admin.auth.totpVerify.mutationOptions({ onSuccess: refresh, onError: (e) => setError(errorText(t, e)) }));
useEffect(() => {
if (stage === 'totp_setup' && !setup && !totpSetup.isPending) totpSetup.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
return (
<div className="auth-wrap">
<Card className="auth-card bc-stack" style={{ padding: 28, gap: 16 }}>
<Logo />
{stage === 'password' ? (
<form
className="bc-stack"
onSubmit={(e) => {
e.preventDefault();
setError(null);
login.mutate({ email, password });
}}
>
<h1 className="bc-h2">{t('admin.login.title')}</h1>
<Field label={t('admin.login.email')} htmlFor="email">
<input id="email" className="bc-input" type="text" autoCapitalize="none" autoComplete="username" value={email} onChange={(e) => setEmail(e.target.value)} required />
</Field>
<Field label={t('admin.login.password')} htmlFor="password">
<input id="password" className="bc-input" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</Field>
{error && <div className="bc-error">{error}</div>}
<Button type="submit" block disabled={login.isPending}>
{t('admin.login.submit')}
</Button>
</form>
) : (
<form
className="bc-stack"
onSubmit={(e) => {
e.preventDefault();
setError(null);
verify.mutate({ code });
}}
>
<h1 className="bc-h2">{t(stage === 'totp_setup' ? 'admin.login.setupTitle' : 'admin.login.totpTitle')}</h1>
{stage === 'totp_setup' && (
<>
<p style={{ margin: 0 }}>{t('admin.login.setupText')}</p>
{setup && (
<div className="bc-stack" style={{ alignItems: 'center' }}>
<img src={setup.qr} alt={t('admin.login.qrAlt')} width={220} height={220} style={{ borderRadius: 16, background: '#fff' }} />
<code className="mono" style={{ wordBreak: 'break-all' }}>
{setup.secret}
</code>
</div>
)}
</>
)}
<Field label={t('admin.login.code')} htmlFor="code">
<input id="code" className="bc-input" inputMode="numeric" autoComplete="one-time-code" pattern="\d{6}" maxLength={6} value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))} required autoFocus />
</Field>
{error && <div className="bc-error">{error}</div>}
<Button type="submit" block disabled={verify.isPending || code.length !== 6}>
{t('admin.login.verify')}
</Button>
</form>
)}
</Card>
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { LOCALES } from '@barev/i18n';
import { Badge, Button, Card, Field } from '@barev/ui';
import QRCode from 'qrcode';
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { Fragment, useEffect, useState } from 'react';
const CATEGORIES = ['cafe', 'restaurant', 'shop', 'beauty', 'other'] as const;
type Draft = { id?: string; category: (typeof CATEGORIES)[number]; name: string; offer: Record<string, string>; address: string; active: boolean };
const EMPTY: Draft = { category: 'cafe', name: '', offer: { ru: '', en: '', zh: '', hy: '', es: '' }, address: '', active: true };
/** Партнёры клуба: что получает клиент по клубной карте — показывается в приложении на вкладке «Скидки» */
export function Partners() {
const { t, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.admin.partners.list.queryOptions());
const [d, setD] = useState<Draft | null>(null);
const [open, setOpen] = useState<string | null>(null);
const save = useMutation(
trpc.admin.partners.save.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
setD(null);
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.nav.partners')}</h1>
<Button size="sm" onClick={() => setD(EMPTY)}>
{t('admin.add')}
</Button>
</div>
{d && (
<Card className="form-grid">
<Field label={t('admin.partners.name')}>
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} />
</Field>
<Field label={t('admin.partners.category')}>
<select className="bc-input" value={d.category} onChange={(e) => setD({ ...d, category: e.target.value as Draft['category'] })}>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`client.partners.cat.${c}`)}
</option>
))}
</select>
</Field>
{LOCALES.map((l) => (
<Field key={l} label={`${t('admin.partners.offer')} · ${l.toUpperCase()}`}>
<input className="bc-input" value={d.offer[l] ?? ''} placeholder={l === 'ru' ? '−15% на всё меню' : ''} onChange={(e) => setD({ ...d, offer: { ...d.offer, [l]: e.target.value } })} />
</Field>
))}
<Field label={t('admin.partners.address')}>
<input className="bc-input" value={d.address} onChange={(e) => setD({ ...d, address: e.target.value })} />
</Field>
<label className="bc-row">
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
</label>
<div className="full bc-row">
<Button
disabled={!d.name.trim() || !d.offer.ru?.trim()}
onClick={() => save.mutate({ ...d, address: d.address.trim() || null, offer: { ru: d.offer.ru ?? '', en: d.offer.en ?? '', zh: d.offer.zh ?? '', hy: d.offer.hy ?? '', es: d.offer.es ?? '' } })}
>
{t('admin.save')}
</Button>
<Button variant="light" onClick={() => setD(null)}>
{t('common.cancel')}
</Button>
</div>
</Card>
)}
<table className="grid">
<thead>
<tr>
<th>{t('admin.partners.name')}</th>
<th>{t('admin.partners.offer')}</th>
<th>{t('admin.partners.address')}</th>
<th />
</tr>
</thead>
<tbody>
{list.data?.map((p) => {
const offer = p.offer as Record<string, string>;
return (
<Fragment key={p.id}>
<tr style={{ opacity: p.active ? 1 : 0.5 }}>
<td>
<b>{p.name}</b>
<div className="bc-small bc-muted">{t(`client.partners.cat.${p.category}`)}</div>
<div className="bc-small bc-muted">
{t('admin.partners.summary', { offers: p.offers.length, members: p.members.length, visits: p._count.usages })}
</div>
</td>
<td>{offer[locale] || offer.ru}</td>
<td className="bc-small">{p.address ?? '—'}</td>
<td>
<div className="bc-chips">
<Button size="sm" variant="light" onClick={() => setOpen(open === p.id ? null : p.id)}>
{t('admin.partners.manage')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setD({ id: p.id, category: p.category as Draft['category'], name: p.name, offer, address: p.address ?? '', active: p.active })}>
{t('admin.edit')}
</Button>
</div>
</td>
</tr>
{open === p.id && (
<tr>
<td colSpan={4} style={{ background: 'var(--bc-bg)' }}>
<PartnerDetails partner={p} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</>
);
}
type Offer = { id: string; title: unknown; minLevel: 'BAREV_PLUS' | 'ELITE' | null; limit: 'NONE' | 'ONCE' | 'WEEK' | 'MONTH'; limitCount: number; active: boolean };
type OfferDraft = { id?: string; title: Record<string, string>; minLevel: Offer['minLevel']; limit: Offer['limit']; limitCount: number; active: boolean };
const LEVELS = [null, 'BAREV_PLUS', 'ELITE'] as const;
const LIMITS = ['NONE', 'ONCE', 'WEEK', 'MONTH'] as const;
/** Предложения партнёра по уровням и лимитам, ссылка для собственника, команда */
function PartnerDetails({ partner }: { partner: { id: string; offers: Offer[]; members: { id: string; role: 'OWNER' | 'STAFF'; user: { firstName: string | null; lastName: string | null; username: string | null } }[] } }) {
const { t, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [od, setOd] = useState<OfferDraft | null>(null);
const [link, setLink] = useState<string | null>(null);
const [qr, setQr] = useState('');
useEffect(() => {
if (link) void QRCode.toString(link, { type: 'svg', margin: 1 }).then(setQr);
}, [link]);
const done = {
onSuccess: () => {
toast(t('admin.saved'));
setOd(null);
void qc.invalidateQueries();
},
onError: (e: unknown) => toast(errorText(t, e), 'error'),
};
const saveOffer = useMutation(trpc.admin.partners.saveOffer.mutationOptions(done));
const removeMember = useMutation(trpc.admin.partners.removeMember.mutationOptions(done));
const owner = useMutation(trpc.admin.partners.ownerInvite.mutationOptions({ onSuccess: (r) => setLink(r.url), onError: done.onError }));
const levelName = (l: Offer['minLevel']) => (l === 'ELITE' ? 'Barev Elite' : l === 'BAREV_PLUS' ? 'Barev+' : t('admin.partners.allMembers'));
const limitName = (o: { limit: Offer['limit']; limitCount: number }) => t(`admin.partners.limit_${o.limit}`, { count: o.limitCount });
return (
<div className="bc-stack" style={{ gap: 12 }}>
<div className="bc-between">
<b>{t('admin.partners.offers')}</b>
<Button size="sm" onClick={() => setOd({ title: { ru: '', en: '', zh: '', hy: '', es: '' }, minLevel: null, limit: 'NONE', limitCount: 1, active: true })}>
{t('admin.add')}
</Button>
</div>
{od && (
<div className="form-grid" style={{ background: 'var(--bc-surface)', padding: 12, borderRadius: 16 }}>
{LOCALES.map((l) => (
<Field key={l} label={`${t('admin.partners.offerTitle')} · ${l.toUpperCase()}`}>
<input className="bc-input" value={od.title[l] ?? ''} placeholder={l === 'ru' ? 'Кофе в подарок' : ''} onChange={(e) => setOd({ ...od, title: { ...od.title, [l]: e.target.value } })} />
</Field>
))}
<Field label={t('admin.partners.minLevel')}>
<select className="bc-input" value={od.minLevel ?? ''} onChange={(e) => setOd({ ...od, minLevel: (e.target.value || null) as Offer['minLevel'] })}>
{LEVELS.map((l) => (
<option key={l ?? 'all'} value={l ?? ''}>
{levelName(l)}
</option>
))}
</select>
</Field>
<Field label={t('admin.partners.limit')}>
<select className="bc-input" value={od.limit} onChange={(e) => setOd({ ...od, limit: e.target.value as Offer['limit'] })}>
{LIMITS.map((l) => (
<option key={l} value={l}>
{t(`admin.partners.limitKind_${l}`)}
</option>
))}
</select>
</Field>
{(od.limit === 'WEEK' || od.limit === 'MONTH') && (
<Field label={t('admin.partners.limitCount')}>
<input className="bc-input" type="number" min={1} max={100} value={od.limitCount} onChange={(e) => setOd({ ...od, limitCount: Number(e.target.value) || 1 })} />
</Field>
)}
<label className="bc-row">
<input type="checkbox" checked={od.active} onChange={(e) => setOd({ ...od, active: e.target.checked })} /> {t('admin.active')}
</label>
<div className="full bc-row">
<Button disabled={!od.title.ru?.trim()} onClick={() => saveOffer.mutate({ ...od, partnerId: partner.id, title: { ru: od.title.ru ?? '', en: od.title.en ?? '', zh: od.title.zh ?? '', hy: od.title.hy ?? '', es: od.title.es ?? '' } })}>
{t('admin.save')}
</Button>
<Button variant="light" onClick={() => setOd(null)}>
{t('common.cancel')}
</Button>
</div>
</div>
)}
{partner.offers.length === 0 && <p className="bc-small bc-muted" style={{ margin: 0 }}>{t('admin.partners.noOffers')}</p>}
{partner.offers.map((o) => {
const title = o.title as Record<string, string>;
return (
<div key={o.id} className="bc-between" style={{ opacity: o.active ? 1 : 0.5 }}>
<span>
<b>{title[locale] || title.ru}</b>{' '}
<Badge style={{ background: o.minLevel === 'ELITE' ? 'var(--bc-perks)' : o.minLevel ? 'var(--bc-pink)' : 'var(--bc-surface)' }}>{levelName(o.minLevel)}</Badge>{' '}
<span className="bc-small bc-muted">{limitName(o)}</span>
</span>
<Button size="sm" variant="ghost" onClick={() => setOd({ id: o.id, title: { ...title }, minLevel: o.minLevel, limit: o.limit, limitCount: o.limitCount, active: o.active })}>
{t('admin.edit')}
</Button>
</div>
);
})}
<div className="bc-between" style={{ marginTop: 6 }}>
<b>{t('admin.partners.team')}</b>
<Button size="sm" variant="outline" onClick={() => owner.mutate({ partnerId: partner.id })}>
{t('admin.partners.ownerLink')}
</Button>
</div>
{link && (
<div className="bc-row" style={{ alignItems: 'flex-start', gap: 14 }}>
<div style={{ width: 160, height: 160, background: '#fff', borderRadius: 12, padding: 6 }} dangerouslySetInnerHTML={{ __html: qr }} />
<div className="bc-stack" style={{ gap: 6, flex: 1 }}>
<span className="bc-small">{t('admin.partners.ownerLinkHint')}</span>
<input className="bc-input mono" readOnly value={link} onFocus={(e) => e.target.select()} />
</div>
</div>
)}
{partner.members.length === 0 && <p className="bc-small bc-muted" style={{ margin: 0 }}>{t('admin.partners.noTeam')}</p>}
{partner.members.map((m) => (
<div key={m.id} className="bc-between">
<span>
<b>
{m.user.firstName} {m.user.lastName ?? ''}
</b>
{m.user.username && <span className="bc-small bc-muted"> @{m.user.username}</span>}
<span className="bc-small bc-muted"> · {t(m.role === 'OWNER' ? 'pro.partner.owner' : 'pro.partner.staff')}</span>
</span>
<Button size="sm" variant="ghost" onClick={async () => (await confirmDialog(t('admin.partners.removeConfirm'))) && removeMember.mutate({ memberId: m.id })}>
{t('pro.partner.remove')}
</Button>
</div>
))}
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CATALOG, type PayoutRule, type PayoutRules } from '@barev/core';
import { Badge, Button, Card, Field } from '@barev/ui';
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
const cleaning = CATALOG.find((s) => s.code === 'cleaning')!;
const VARIANTS = cleaning.pricing.kind === 'rooms' ? Object.keys(cleaning.pricing.tiers) : [];
const EXTRAS = cleaning.pricing.kind === 'rooms' ? Object.keys(cleaning.pricing.extras) : [];
interface Draft {
id?: string;
name: string;
rules: PayoutRules;
active: boolean;
sortOrder: number;
}
/** Пресеты ставок клинеров (джун / мидл / прайват): по каждой строке цены — фикс или % */
export function Presets({ isOwner }: { isOwner: boolean }) {
const { t } = useI18n();
const trpc = useTRPC();
const list = useQuery(trpc.admin.presets.list.queryOptions());
const [edit, setEdit] = useState<Draft | null>(null);
const empty = (): Draft => ({
name: '',
active: true,
sortOrder: (list.data?.length ?? 0) + 1,
rules: {
base: Object.fromEntries(VARIANTS.map((v) => [v, { type: 'PERCENT' as const, value: 60 }])),
room: { type: 'PERCENT', value: 60 },
bathroom: { type: 'PERCENT', value: 60 },
extras: Object.fromEntries(EXTRAS.map((c) => [c, { type: 'PERCENT' as const, value: 60 }])),
},
});
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.nav.presets')}</h1>
{isOwner && (
<Button size="sm" onClick={() => setEdit(empty())}>
{t('admin.presets.add')}
</Button>
)}
</div>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('admin.presets.hint')}
</p>
{edit && <PresetForm draft={edit} onClose={() => setEdit(null)} />}
<div className="summary">
{list.data?.map((p) => (
<Card key={p.id} className="bc-stack" style={{ opacity: p.active ? 1 : 0.5 }}>
<div className="bc-between">
<h2 className="bc-h3">{p.name}</h2>
<Badge style={{ background: 'var(--bc-bg)' }}>{t('admin.presets.providers', { count: p.providers })}</Badge>
</div>
<div className="bc-small">
{VARIANTS.map((v) => (
<div key={v}>
{t(`params.cleaning.cleaningType.${v}`)}: <RuleText rule={p.rules.base[v]} />
</div>
))}
<div>
{t('admin.presets.room')}: <RuleText rule={p.rules.room} />
</div>
<div>
{t('admin.presets.bathroom')}: <RuleText rule={p.rules.bathroom} />
</div>
</div>
{isOwner && (
<Button size="sm" variant="light" onClick={() => setEdit({ id: p.id, name: p.name, rules: p.rules, active: p.active, sortOrder: p.sortOrder })}>
{t('admin.edit')}
</Button>
)}
</Card>
))}
</div>
</>
);
}
function RuleText({ rule }: { rule: PayoutRule | undefined }) {
const { amd } = useI18n();
if (!rule) return <>—</>;
return <b>{rule.type === 'PERCENT' ? `${rule.value}%` : amd(rule.value)}</b>;
}
function PresetForm({ draft, onClose }: { draft: Draft; onClose: () => void }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [d, setD] = useState(draft);
const save = useMutation(
trpc.admin.presets.save.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
onClose();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const rules = d.rules;
const setRules = (r: PayoutRules) => setD({ ...d, rules: r });
/** Применить один процент ко всем строкам — быстрый старт */
const [bulk, setBulk] = useState(60);
const applyAll = () => {
const r: PayoutRule = { type: 'PERCENT', value: bulk };
setRules({
base: Object.fromEntries(VARIANTS.map((v) => [v, r])),
room: r,
bathroom: r,
extras: Object.fromEntries(EXTRAS.map((c) => [c, r])),
});
};
const rows: { key: string; label: string; unit: string; rule: PayoutRule | undefined; set: (r: PayoutRule) => void }[] = [
...VARIANTS.map((v) => ({
key: `base:${v}`,
label: `${t('services.cleaning.name')} · ${t(`params.cleaning.cleaningType.${v}`)}`,
unit: t('admin.presets.perOrder'),
rule: rules.base[v],
set: (r: PayoutRule) => setRules({ ...rules, base: { ...rules.base, [v]: r } }),
})),
{ key: 'room', label: t('admin.presets.room'), unit: t('admin.presets.perRoom'), rule: rules.room, set: (r) => setRules({ ...rules, room: r }) },
{ key: 'bathroom', label: t('admin.presets.bathroom'), unit: t('admin.presets.perBathroom'), rule: rules.bathroom, set: (r) => setRules({ ...rules, bathroom: r }) },
...EXTRAS.map((c) => ({
key: `extra:${c}`,
label: t(`extras.${c}.name`),
unit: cleaning.pricing.kind === 'rooms' && cleaning.pricing.extras[c]?.unit !== 'service' ? t(`extras.${c}.per`) : t('admin.presets.perOrder'),
rule: rules.extras[c],
set: (r: PayoutRule) => setRules({ ...rules, extras: { ...rules.extras, [c]: r } }),
})),
];
return (
<Card className="bc-stack">
<div className="form-grid">
<Field label={t('admin.presets.name')}>
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} maxLength={60} />
</Field>
<Field label={t('admin.presets.applyAll')}>
<div className="bc-row">
<input className="bc-input" type="number" min={0} max={100} value={bulk} onChange={(e) => setBulk(Number(e.target.value))} style={{ width: 100 }} />
<span>%</span>
<Button size="sm" variant="light" onClick={applyAll}>
{t('common.apply')}
</Button>
</div>
</Field>
</div>
<table className="grid">
<thead>
<tr>
<th>{t('admin.presets.line')}</th>
<th>{t('admin.presets.type')}</th>
<th>{t('admin.presets.value')}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const rule = r.rule ?? { type: 'PERCENT' as const, value: 0 };
return (
<tr key={r.key}>
<td>{r.label}</td>
<td>
<select className="bc-input" value={rule.type} style={{ minHeight: 38, padding: '6px 10px' }} onChange={(e) => r.set({ ...rule, type: e.target.value as PayoutRule['type'] })}>
<option value="PERCENT">{t('admin.presets.percent')}</option>
<option value="FIXED">{t('admin.presets.fixed', { unit: r.unit })}</option>
</select>
</td>
<td>
<div className="bc-row">
<input
className="bc-input"
type="number"
min={0}
max={rule.type === 'PERCENT' ? 100 : undefined}
value={rule.value}
style={{ minHeight: 38, padding: '6px 10px', width: 120 }}
onChange={(e) => r.set({ ...rule, value: Math.max(0, Math.round(Number(e.target.value) || 0)) })}
/>
<span className="bc-small bc-muted">{rule.type === 'PERCENT' ? '%' : `֏ ${r.unit}`}</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<label className="bc-row">
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
</label>
<div className="bc-row">
<Button disabled={!d.name.trim() || save.isPending} onClick={() => save.mutate({ ...d, name: d.name.trim(), role: 'CLEANER' })}>
{t('admin.save')}
</Button>
<Button variant="light" onClick={onClose}>
{t('common.cancel')}
</Button>
</div>
</Card>
);
}
+227
View File
@@ -0,0 +1,227 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { averageRating, PROVIDER_ROLES, type Locale, type ProviderRole } from '@barev/core';
import { Badge, Button, Card, Field } from '@barev/ui';
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { Copyable, LOCALE_OPTIONS, MultiChips, ScheduleEditor, type ScheduleRow } from '../components/form.tsx';
interface Draft {
id?: string;
name: string;
phone: string;
role: ProviderRole;
companyId: string;
languages: Locale[];
districtIds: string[];
schedule: ScheduleRow[];
rateType: 'FIXED' | 'PERCENT';
rateValue: number;
payoutPresetId: string;
active: boolean;
}
const EMPTY: Draft = {
name: '',
phone: '',
role: 'CLEANER',
companyId: '',
languages: ['ru'],
districtIds: [],
schedule: [0, 1, 2, 3, 4, 5].map((weekday) => ({ weekday, startMinute: 540, endMinute: 1200 })),
rateType: 'PERCENT',
rateValue: 70,
payoutPresetId: '',
active: true,
};
/** ADM-5: исполнители — роль, языки, районы, график, ставка (PRO-8) */
export function Providers() {
const { t } = useI18n();
const trpc = useTRPC();
const list = useQuery(trpc.admin.providers.list.queryOptions());
const config = useQuery(trpc.admin.dictionaries.config.queryOptions());
const [edit, setEdit] = useState<Draft | null>(null);
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.nav.providers')}</h1>
<Button size="sm" onClick={() => setEdit({ ...EMPTY })}>
{t('admin.providers.add')}
</Button>
</div>
{edit && <ProviderForm draft={edit} onClose={() => setEdit(null)} />}
<table className="grid">
<thead>
<tr>
<th>{t('admin.providers.name')}</th>
<th>{t('admin.providers.role')}</th>
<th>{t('admin.providers.languages')}</th>
<th>{t('admin.providers.districts')}</th>
<th>{t('admin.providers.rate')}</th>
<th>{t('admin.providers.rating')}</th>
<th>{t('admin.providers.telegram')}</th>
</tr>
</thead>
<tbody>
{list.data?.map((p) => (
<tr
key={p.id}
className="clickable"
style={{ opacity: p.active ? 1 : 0.5 }}
onClick={() =>
setEdit({
id: p.id,
name: p.name,
phone: p.phone ?? '',
role: p.role,
companyId: p.companyId ?? '',
languages: p.languages,
districtIds: p.districts.map((d) => d.districtId),
schedule: p.schedule.map((s) => ({ weekday: s.weekday, startMinute: s.startMinute, endMinute: s.endMinute })),
rateType: p.rateType,
rateValue: p.rateValue,
payoutPresetId: p.payoutPresetId ?? '',
active: p.active,
})
}
>
<td>
<b>{p.name}</b>
{p.company && <div className="bc-small bc-muted">{p.company.name}</div>}
<a href={`#/concierge?provider=${p.id}`} className="bc-small" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
{t('admin.concierge.write')}
</a>
</td>
<td>{t(`roles.${p.role}`)}</td>
<td>{p.languages.map((l) => l.toUpperCase()).join(' ')}</td>
<td className="bc-small">{p.districts.map((d) => t(`districts.${d.district.code}`)).join(', ')}</td>
<td>{p.company ? t('admin.providers.companyRate') : p.payoutPreset ? p.payoutPreset.name : p.rateType === 'PERCENT' ? `${p.rateValue}%` : `${p.rateValue} ֏`}</td>
<td>{averageRating(p.ratingSum, p.ratingCount) ?? '—'}</td>
<td onClick={(e) => e.stopPropagation()}>
{p.userId ? <Badge style={{ background: 'var(--bc-home)' }}>{t('admin.providers.linked')}</Badge> : config.data && <Copyable text={`https://t.me/${config.data.proBot}?start=${p.inviteCode}`} />}
</td>
</tr>
))}
</tbody>
</table>
</>
);
}
function ProviderForm({ draft: initial, onClose }: { draft: Draft; onClose: () => void }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const districts = useQuery(trpc.admin.dictionaries.districts.queryOptions());
const companies = useQuery(trpc.admin.companies.list.queryOptions());
const presets = useQuery(trpc.admin.presets.list.queryOptions());
const [d, setD] = useState(initial);
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD({ ...d, [k]: v });
const onDone = () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
onClose();
};
const create = useMutation(trpc.admin.providers.create.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
const update = useMutation(trpc.admin.providers.update.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') }));
const reset = useMutation(trpc.admin.providers.resetInvite.mutationOptions({ onSuccess: onDone }));
function save() {
const payload = { ...d, phone: d.phone || null, companyId: d.companyId || null, payoutPresetId: d.payoutPresetId || null };
if (d.id) update.mutate({ ...payload, id: d.id });
else create.mutate(payload);
}
return (
<Card className="bc-stack">
<h2 className="bc-h3">{d.id ? d.name : t('admin.providers.add')}</h2>
<div className="form-grid">
<Field label={t('admin.providers.name')}>
<input className="bc-input" value={d.name} onChange={(e) => set('name', e.target.value)} />
</Field>
<Field label={t('admin.providers.phone')}>
<input className="bc-input" value={d.phone} onChange={(e) => set('phone', e.target.value)} />
</Field>
<Field label={t('admin.providers.role')}>
<select className="bc-input" value={d.role} onChange={(e) => set('role', e.target.value as ProviderRole)}>
{PROVIDER_ROLES.map((r) => (
<option key={r} value={r}>
{t(`roles.${r}`)}
</option>
))}
</select>
</Field>
<Field label={t('admin.providers.company')}>
<select className="bc-input" value={d.companyId} onChange={(e) => set('companyId', e.target.value)}>
<option value="">{t('admin.providers.freelancer')}</option>
{companies.data?.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</Field>
<div className="full">
<Field label={t('admin.providers.languages')}>
<MultiChips value={d.languages} options={LOCALE_OPTIONS} onChange={(v) => set('languages', v)} />
</Field>
</div>
<div className="full">
<Field label={t('admin.providers.districts')}>
<MultiChips value={d.districtIds} options={(districts.data ?? []).map((x) => ({ value: x.id, label: t(`districts.${x.code}`) }))} onChange={(v) => set('districtIds', v)} />
</Field>
</div>
{!d.companyId && (
<Field label={t('admin.providers.preset')}>
<select className="bc-input" value={d.payoutPresetId} onChange={(e) => set('payoutPresetId', e.target.value)}>
<option value="">{t('admin.providers.ownRate')}</option>
{presets.data
?.filter((p) => p.role === d.role && (p.active || p.id === d.payoutPresetId))
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</Field>
)}
{!d.companyId && !d.payoutPresetId && (
<>
<Field label={t('admin.providers.rateType')}>
<select className="bc-input" value={d.rateType} onChange={(e) => set('rateType', e.target.value as Draft['rateType'])}>
<option value="PERCENT">{t('admin.rate.PERCENT')}</option>
<option value="FIXED">{t('admin.rate.FIXED')}</option>
</select>
</Field>
<Field label={d.rateType === 'PERCENT' ? t('admin.providers.ratePercent') : t('admin.providers.rateFixed')}>
<input className="bc-input" type="number" min={0} value={d.rateValue} onChange={(e) => set('rateValue', Number(e.target.value))} />
</Field>
</>
)}
<div className="full">
<Field label={t('admin.providers.schedule')}>
<ScheduleEditor value={d.schedule} onChange={(v) => set('schedule', v)} />
</Field>
</div>
<label className="bc-row">
<input type="checkbox" checked={d.active} onChange={(e) => set('active', e.target.checked)} /> {t('admin.active')}
</label>
</div>
<div className="bc-row">
<Button onClick={save} disabled={create.isPending || update.isPending || !d.name || !d.languages.length}>
{t('admin.save')}
</Button>
<Button variant="light" onClick={onClose}>
{t('common.cancel')}
</Button>
{d.id && (
<Button variant="ghost" onClick={() => reset.mutate({ id: d.id! })}>
{t('admin.providers.resetInvite')}
</Button>
)}
</div>
</Card>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Badge, Button, Chip } from '@barev/ui';
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
const STATUSES = ['NEW', 'DONE', 'CANCELLED'] as const;
type Status = (typeof STATUSES)[number];
const TONE: Record<Status, string> = { NEW: 'var(--bc-perks)', DONE: 'var(--bc-home)', CANCELLED: 'var(--bc-line)' };
/** Заявки из приложения: подписка (включить одной кнопкой) или «перезвоните» по услуге */
export function Requests() {
const { t, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [status, setStatus] = useState<Status | undefined>('NEW');
const list = useQuery({ ...trpc.admin.requests.list.queryOptions({ status }), refetchInterval: 15_000 });
const done = {
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
},
onError: (e: unknown) => toast(errorText(t, e), 'error'),
};
const update = useMutation(trpc.admin.requests.update.mutationOptions(done));
const activate = useMutation(trpc.admin.requests.activate.mutationOptions(done));
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.nav.requests')}</h1>
<div className="bc-chips">
<Chip pressed={!status} onClick={() => setStatus(undefined)}>
{t('admin.leads.all')}
</Chip>
{STATUSES.map((s) => (
<Chip key={s} pressed={status === s} onClick={() => setStatus(s)}>
{t(`admin.requests.status_${s}`)}
</Chip>
))}
</div>
</div>
{list.data?.length === 0 ? (
<p className="bc-muted">{t('admin.requests.empty')}</p>
) : (
<table className="grid">
<thead>
<tr>
<th>{t('admin.leads.number')}</th>
<th>{t('admin.requests.client')}</th>
<th>{t('admin.requests.what')}</th>
<th>{t('admin.requests.comment')}</th>
<th>{t('admin.leads.status')}</th>
</tr>
</thead>
<tbody>
{list.data?.map((r) => {
const u = r.client.user;
const plan = r.plan === 'ELITE' ? 'Barev Elite' : 'Barev+';
return (
<tr key={r.id}>
<td>
<b>{r.number}</b>
<div className="bc-small bc-muted">{dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</div>
</td>
<td>
<b>
{u.firstName} {u.lastName ?? ''}
</b>
{u.username && (
<div>
<a href={`https://t.me/${u.username}`} target="_blank" rel="noopener">
@{u.username}
</a>
</div>
)}
{u.phone && <div className="bc-small">{u.phone}</div>}
<div className="bc-small bc-muted">{u.locale.toUpperCase()}</div>
</td>
<td>
{r.kind === 'SUBSCRIPTION' ? (
<Badge style={{ background: r.plan === 'ELITE' ? 'var(--bc-perks)' : 'var(--bc-pink)' }}>{t('admin.requests.subscription', { plan })}</Badge>
) : (
<b>{t(`services.${r.serviceCode}.name`)}</b>
)}
</td>
<td className="bc-small" style={{ maxWidth: 320 }}>
{r.comment ?? '—'}
</td>
<td>
<Badge style={{ background: TONE[r.status] }}>{t(`admin.requests.status_${r.status}`)}</Badge>
{r.status === 'NEW' && (
<div className="bc-chips" style={{ marginTop: 8 }}>
{r.kind === 'SUBSCRIPTION' && (
<Button size="sm" onClick={async () => (await confirmDialog(t('admin.requests.activateConfirm', { plan }))) && activate.mutate({ id: r.id, days: 30 })}>
{t('admin.requests.activate')}
</Button>
)}
{r.kind === 'SERVICE' && (
<Button size="sm" variant="light" onClick={() => update.mutate({ id: r.id, status: 'DONE' })}>
{t('admin.requests.markDone')}
</Button>
)}
<Button size="sm" variant="light" onClick={() => update.mutate({ id: r.id, status: 'CANCELLED' })}>
{t('admin.webRequests.cancel')}
</Button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { pricingSchema, slotConfigSchema, type Pricing, type SlotConfig } from '@barev/core';
import { Badge, Button, Card, Field } from '@barev/ui';
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
/** ADM-5: направления и услуги — цены, доступность, окно записи, удержание; скидки Barev+ */
export function Services({ isOwner }: { isOwner: boolean }) {
const { t, amd } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.admin.services.list.queryOptions());
const [open, setOpen] = useState<string | null>(null);
const updateCategory = useMutation(
trpc.admin.services.updateCategory.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<>
<h1 className="bc-h2">{t('admin.nav.services')}</h1>
{list.data?.map((c) => (
<Card key={c.id} className="bc-stack">
<div className="bc-between">
<h2 className="bc-h3">{t(`categories.${c.code}.name`)}</h2>
{c.code !== 'PERKS' && (
<label className="bc-row bc-small">
{t('admin.services.plusDiscount')}
<input
className="bc-input"
type="number"
min={0}
max={100}
defaultValue={c.barevPlusDiscountPct}
disabled={!isOwner}
style={{ width: 90, minHeight: 40 }}
onBlur={(e) => Number(e.target.value) !== c.barevPlusDiscountPct && updateCategory.mutate({ id: c.id, barevPlusDiscountPct: Number(e.target.value) })}
/>
%
<span style={{ marginLeft: 12 }}>{t('admin.services.eliteDiscount')}</span>
<input
className="bc-input"
type="number"
min={0}
max={100}
defaultValue={c.eliteDiscountPct}
disabled={!isOwner}
style={{ width: 90, minHeight: 40 }}
onBlur={(e) => Number(e.target.value) !== c.eliteDiscountPct && updateCategory.mutate({ id: c.id, eliteDiscountPct: Number(e.target.value) })}
/>
%
</label>
)}
</div>
{c.services.map((s) => {
const pricing = pricingSchema.parse(s.pricing);
const from = pricing.kind === 'fixed' ? pricing.price : Math.min(...Object.values(pricing.tiers).map((x) => x.base));
return (
<div key={s.id} className="bc-stack" style={{ gap: 8, borderTop: '1px solid var(--bc-line)', paddingTop: 10 }}>
<div className="bc-between">
<div className="bc-row">
<b>{t(`services.${s.code}.name`)}</b>
<Badge style={{ background: s.active ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(s.active ? 'admin.services.active' : 'admin.services.inactive')}</Badge>
<span className="bc-small bc-muted">{t('common.from', { price: amd(from) })}</span>
</div>
<Button size="sm" variant="light" onClick={() => setOpen(open === s.id ? null : s.id)}>
{t(open === s.id ? 'common.close' : 'admin.edit')}
</Button>
</div>
{open === s.id && <ServiceEditor service={{ ...s, pricing, slotConfig: slotConfigSchema.parse(s.slotConfig) }} onSaved={() => setOpen(null)} />}
</div>
);
})}
</Card>
))}
</>
);
}
function ServiceEditor({
service,
onSaved,
}: {
service: { id: string; code: string; active: boolean; lateCancelFeePct: number; pricing: Pricing; slotConfig: SlotConfig; checklist: { code: string; requiresPhoto: boolean }[] };
onSaved: () => void;
}) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [active, setActive] = useState(service.active);
const [fee, setFee] = useState(service.lateCancelFeePct);
const [pricing, setPricing] = useState<Pricing>(service.pricing);
const [slot, setSlot] = useState<SlotConfig>(service.slotConfig);
const save = useMutation(
trpc.admin.services.update.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
onSaved();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const num = (v: string) => Math.max(0, Math.round(Number(v) || 0));
return (
<div className="bc-stack" style={{ background: 'var(--bc-bg)', borderRadius: 20, padding: 16 }}>
<label className="bc-row">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> {t('admin.services.availableToOrder')}
</label>
<h4 style={{ margin: 0 }}>{t('admin.services.prices')}</h4>
{pricing.kind === 'rooms' ? (
<table className="grid">
<thead>
<tr>
<th>{t('admin.services.variant')}</th>
<th>{t('admin.services.base')}</th>
<th>{t('admin.services.perRoom')}</th>
<th>{t('admin.services.baseMinutes')}</th>
<th>{t('admin.services.perRoomMinutes')}</th>
<th>{t('admin.services.perBathroom')}</th>
<th>{t('admin.services.perBathroomMinutes')}</th>
</tr>
</thead>
<tbody>
{Object.entries(pricing.tiers).map(([variant, tier]) => (
<tr key={variant}>
<td>{t(`params.${service.code}.${pricing.variantParam}.${variant}`)}</td>
{(['base', 'perRoom', 'baseMinutes', 'perRoomMinutes', 'perBathroom', 'perBathroomMinutes'] as const).map((k) => (
<td key={k}>
<input
className="bc-input"
type="number"
min={0}
value={tier[k]}
style={{ minHeight: 38, padding: '6px 10px' }}
onChange={(e) => setPricing({ ...pricing, tiers: { ...pricing.tiers, [variant]: { ...tier, [k]: num(e.target.value) } } })}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
) : null}
{pricing.kind === 'rooms' && Object.keys(pricing.extras).length > 0 && (
<>
<h4 style={{ margin: 0 }}>{t('admin.services.extras')}</h4>
<table className="grid">
<thead>
<tr>
<th>{t('admin.services.extra')}</th>
<th>{t('admin.services.extraPrice')}</th>
<th>{t('admin.services.extraMinutes')}</th>
</tr>
</thead>
<tbody>
{Object.entries(pricing.extras).map(([code, e]) => (
<tr key={code}>
<td>
{t(`extras.${code}.name`)}
{e.unit !== 'service' && <span className="bc-small bc-muted"> {t(`extras.${code}.per`)}</span>}
</td>
{(['price', 'minutes'] as const).map((k) => (
<td key={k}>
<input
className="bc-input"
type="number"
min={0}
value={e[k]}
style={{ minHeight: 38, padding: '6px 10px' }}
onChange={(ev) => setPricing({ ...pricing, extras: { ...pricing.extras, [code]: { ...e, [k]: num(ev.target.value) } } })}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</>
)}
{pricing.kind === 'fixed' && (
<div className="form-grid">
<Field label={t('admin.services.price')}>
<input className="bc-input" type="number" min={0} value={pricing.price} onChange={(e) => setPricing({ ...pricing, price: num(e.target.value) })} />
</Field>
<Field label={t('admin.services.minutes')}>
<input className="bc-input" type="number" min={1} value={pricing.minutes} onChange={(e) => setPricing({ ...pricing, minutes: Math.max(1, num(e.target.value)) })} />
</Field>
</div>
)}
<div className="form-grid">
<Field label={t('admin.services.lateFee')}>
<input className="bc-input" type="number" min={0} max={100} value={fee} onChange={(e) => setFee(Math.min(100, num(e.target.value)))} />
</Field>
<Field label={t('admin.services.step')}>
<input className="bc-input" type="number" min={15} step={15} value={slot.stepMinutes} onChange={(e) => setSlot({ ...slot, stepMinutes: Math.max(15, num(e.target.value)) })} />
</Field>
<Field label={t('admin.services.dayStart')}>
<input className="bc-input" type="number" min={0} max={24} value={slot.dayStartMinute / 60} onChange={(e) => setSlot({ ...slot, dayStartMinute: Math.min(24, num(e.target.value)) * 60 })} />
</Field>
<Field label={t('admin.services.dayEnd')}>
<input className="bc-input" type="number" min={0} max={24} value={slot.dayEndMinute / 60} onChange={(e) => setSlot({ ...slot, dayEndMinute: Math.min(24, num(e.target.value)) * 60 })} />
</Field>
<Field label={t('admin.services.leadHours')}>
<input className="bc-input" type="number" min={0} value={slot.leadHours} onChange={(e) => setSlot({ ...slot, leadHours: num(e.target.value) })} />
</Field>
<Field label={t('admin.services.horizon')}>
<input className="bc-input" type="number" min={1} value={slot.horizonDays} onChange={(e) => setSlot({ ...slot, horizonDays: Math.max(1, num(e.target.value)) })} />
</Field>
</div>
{service.checklist.length > 0 && (
<div className="bc-small">
<b>{t('admin.order.checklist')}:</b> {service.checklist.map((c) => `${t(`checklist.${c.code}`)}${c.requiresPhoto ? ' 📷' : ''}`).join(' · ')}
</div>
)}
<div>
<Button onClick={() => save.mutate({ id: service.id, active, lateCancelFeePct: fee, pricing, slotConfig: slot })} disabled={save.isPending}>
{t('admin.save')}
</Button>
</div>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { SETTING_KEYS, type Settings as S } from '@barev/core';
import { LOCALES } from '@barev/i18n';
import { Button, Card, Field } from '@barev/ui';
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
/** Настройки владельца: пороги, сроки, цены подписки; способы оплаты; администраторы */
export function Settings() {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const settings = useQuery(trpc.admin.settings.get.queryOptions());
const [draft, setDraft] = useState<S | null>(null);
useEffect(() => {
if (settings.data) setDraft(settings.data);
}, [settings.data]);
const save = useMutation(
trpc.admin.settings.update.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<>
<h1 className="bc-h2">{t('admin.nav.settings')}</h1>
{draft && (
<Card className="bc-stack">
<div className="form-grid">
{SETTING_KEYS.map((k) => (
<Field key={k} label={t(`admin.settings.${k.replace('.', '_')}`)}>
<input
className="bc-input"
type={typeof draft[k] === 'number' ? 'number' : 'text'}
value={String(draft[k])}
onChange={(e) => setDraft({ ...draft, [k]: typeof draft[k] === 'number' ? Number(e.target.value) : e.target.value })}
/>
</Field>
))}
</div>
<div>
<Button onClick={() => save.mutate(draft)} disabled={save.isPending}>
{t('admin.save')}
</Button>
</div>
</Card>
)}
<PaymentMethods />
<Admins />
</>
);
}
function PaymentMethods() {
const { t, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const methods = useQuery(trpc.admin.dictionaries.paymentMethods.queryOptions());
const [d, setD] = useState<{ id?: string; code: string; name: Record<string, string>; active: boolean; sortOrder: number } | null>(null);
const save = useMutation(
trpc.admin.dictionaries.savePaymentMethod.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
setD(null);
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<Card className="bc-stack">
<div className="bc-between">
<h2 className="bc-h3">{t('admin.settings.paymentMethods')}</h2>
<Button size="sm" variant="light" onClick={() => setD({ code: '', name: { ru: '', en: '', zh: '', hy: '', es: '' }, active: true, sortOrder: (methods.data?.length ?? 0) + 1 })}>
{t('admin.add')}
</Button>
</div>
{methods.data?.map((m) => (
<div key={m.id} className="bc-between">
<span style={{ opacity: m.active ? 1 : 0.5 }}>
<b>{(m.name as Record<string, string>)[locale] || (m.name as Record<string, string>).ru}</b> <span className="mono bc-muted">{m.code}</span>
</span>
<Button size="sm" variant="ghost" onClick={() => setD({ id: m.id, code: m.code, name: m.name as Record<string, string>, active: m.active, sortOrder: m.sortOrder })}>
{t('admin.edit')}
</Button>
</div>
))}
{d && (
<div className="form-grid">
<Field label={t('admin.settings.code')}>
<input className="bc-input mono" value={d.code} onChange={(e) => setD({ ...d, code: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '') })} />
</Field>
{LOCALES.map((l) => (
<Field key={l} label={`${t('admin.settings.name')} · ${l.toUpperCase()}`}>
<input className="bc-input" value={d.name[l] ?? ''} onChange={(e) => setD({ ...d, name: { ...d.name, [l]: e.target.value } })} />
</Field>
))}
<label className="bc-row">
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
</label>
<div className="full bc-row">
<Button disabled={!d.code || !d.name.ru} onClick={() => save.mutate({ ...d, name: { ru: d.name.ru ?? '', en: d.name.en ?? '', zh: d.name.zh ?? '', hy: d.name.hy ?? '', es: d.name.es ?? '' } })}>
{t('admin.save')}
</Button>
<Button variant="light" onClick={() => setD(null)}>
{t('common.cancel')}
</Button>
</div>
</div>
)}
</Card>
);
}
function Admins() {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const admins = useQuery(trpc.admin.admins.list.queryOptions());
const [d, setD] = useState({ email: '', name: '', role: 'OPERATOR' as 'OWNER' | 'OPERATOR', password: '' });
const create = useMutation(
trpc.admin.admins.create.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
setD({ email: '', name: '', role: 'OPERATOR', password: '' });
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const setActive = useMutation(trpc.admin.admins.setActive.mutationOptions({ onSuccess: () => qc.invalidateQueries(), onError: (e) => toast(errorText(t, e), 'error') }));
return (
<Card className="bc-stack">
<h2 className="bc-h3">{t('admin.settings.admins')}</h2>
{admins.data?.map((a) => (
<div key={a.id} className="bc-between" style={{ opacity: a.active ? 1 : 0.5 }}>
<span>
<b>{a.name}</b> · {a.email} · {t(`admin.roles.${a.role}`)} {a.totpEnabled ? '· 2FA ✓' : ''}
</span>
<Button size="sm" variant="ghost" onClick={() => setActive.mutate({ id: a.id, active: !a.active })}>
{t(a.active ? 'admin.settings.disable' : 'admin.settings.enable')}
</Button>
</div>
))}
<div className="form-grid">
<Field label={t('admin.login.email')}>
<input className="bc-input" type="text" autoCapitalize="none" value={d.email} onChange={(e) => setD({ ...d, email: e.target.value })} />
</Field>
<Field label={t('admin.settings.name')}>
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} />
</Field>
<Field label={t('admin.settings.role')}>
<select className="bc-input" value={d.role} onChange={(e) => setD({ ...d, role: e.target.value as 'OWNER' | 'OPERATOR' })}>
<option value="OPERATOR">{t('admin.roles.OPERATOR')}</option>
<option value="OWNER">{t('admin.roles.OWNER')}</option>
</select>
</Field>
<Field label={t('admin.settings.tempPassword')}>
<input className="bc-input" type="text" value={d.password} onChange={(e) => setD({ ...d, password: e.target.value })} minLength={10} />
</Field>
</div>
<div>
<Button size="sm" disabled={!d.email || !d.name || d.password.length < 10 || create.isPending} onClick={() => create.mutate(d)}>
{t('admin.settings.addAdmin')}
</Button>
</div>
</Card>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Badge, Button, Card } from '@barev/ui';
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
/** Уведомления оператору в Telegram через рабочий бот: заявки, заказы, сообщения — с ответом прямо из Telegram */
export function Telegram() {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const state = useQuery({ ...trpc.admin.auth.telegram.queryOptions(), refetchInterval: 5000 });
const link = useMutation(
trpc.admin.auth.telegramLink.mutationOptions({
onSuccess: (r) => window.open(r.url, '_blank', 'noopener'),
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const settings = useMutation(
trpc.admin.auth.telegramSettings.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const s = state.data;
return (
<>
<h1 className="bc-h2">{t('admin.nav.telegram')}</h1>
<Card className="bc-stack" style={{ maxWidth: 640 }}>
<div className="bc-between">
<h2 className="bc-h3">{t('admin.telegram.title', { bot: s?.bot ?? '' })}</h2>
{s && <Badge style={{ background: s.linked ? 'var(--bc-home)' : 'var(--bc-line)' }}>{t(s.linked ? 'admin.telegram.linked' : 'admin.telegram.notLinked')}</Badge>}
</div>
<p style={{ margin: 0 }}>{t('admin.telegram.what')}</p>
<ul className="bc-small" style={{ margin: 0, paddingLeft: 18, lineHeight: 1.7 }}>
<li>{t('admin.telegram.e1')}</li>
<li>{t('admin.telegram.e2')}</li>
<li>{t('admin.telegram.e3')}</li>
<li>{t('admin.telegram.e4')}</li>
</ul>
{s?.linked ? (
<>
<label className="bc-row">
<input type="checkbox" checked={s.notify} onChange={(e) => settings.mutate({ notify: e.target.checked })} /> {t('admin.telegram.notify')}
</label>
<div className="bc-row">
<Button variant="light" onClick={() => link.mutate()}>
{t('admin.telegram.relink')}
</Button>
<Button variant="ghost" onClick={() => settings.mutate({ unlink: true })}>
{t('admin.telegram.unlink')}
</Button>
</div>
</>
) : (
<>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('admin.telegram.how')}
</p>
<div>
<Button onClick={() => link.mutate()} disabled={link.isPending}>
{t('admin.telegram.connect')}
</Button>
</div>
</>
)}
</Card>
</>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { LOCALES } from '@barev/i18n';
import { Badge, Button, Card, Field } from '@barev/ui';
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
type Kind = 'STEP' | 'DOC';
type L = Record<string, string>;
type Draft = { id?: string; kind: Kind; name: L; hint: L; active: boolean; inTurnkey: boolean };
const empty = (): L => Object.fromEntries(LOCALES.map((l) => [l, '']));
const full = (v: L) => ({ ru: v.ru ?? '', en: v.en ?? '', zh: v.zh ?? '', hy: v.hy ?? '', es: v.es ?? '' });
/**
* Общий список шагов переезда и документов. Новые клиенты получают его копию в свой план;
* у отдельного клиента шаги и документы меняются в разделе «Переезд».
*/
export function Templates() {
const { t, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.admin.leads.templates.queryOptions());
const [d, setD] = useState<Draft | null>(null);
const done = {
onSuccess: () => {
toast(t('admin.saved'));
setD(null);
void qc.invalidateQueries();
},
onError: (e: unknown) => toast(errorText(t, e), 'error'),
};
const save = useMutation(trpc.admin.leads.saveTemplate.mutationOptions(done));
const move = useMutation(trpc.admin.leads.moveTemplate.mutationOptions({ onSuccess: () => void qc.invalidateQueries() }));
const section = (kind: Kind) => {
const rows = (list.data ?? []).filter((x) => x.kind === kind);
return (
<Card className="bc-stack">
<div className="bc-between">
<div>
<h2 className="bc-h3">{t(`admin.templates.${kind}`)}</h2>
<div className="bc-small bc-muted">{t(`admin.templates.${kind}_hint`)}</div>
</div>
<Button size="sm" onClick={() => setD({ kind, name: empty(), hint: empty(), active: true, inTurnkey: kind === 'STEP' })}>
{t('admin.add')}
</Button>
</div>
{d?.kind === kind && <Editor d={d} setD={setD} onSave={() => save.mutate({ ...d, name: full(d.name), hint: kind === 'DOC' ? full(d.hint) : null })} />}
<table className="grid">
<tbody>
{rows.map((x, i) => {
const name = x.name as L;
const hint = (x.hint as L | null) ?? null;
return (
<tr key={x.id} style={{ opacity: x.active ? 1 : 0.5 }}>
<td style={{ width: 70 }}>
<button className="lead-x" disabled={i === 0} onClick={() => move.mutate({ id: x.id, dir: -1 })} aria-label="↑">
↑
</button>
<button className="lead-x" disabled={i === rows.length - 1} onClick={() => move.mutate({ id: x.id, dir: 1 })} aria-label="↓">
↓
</button>
</td>
<td>
<b>{name[locale] || name.ru}</b>
{(hint?.[locale] || hint?.ru) && <div className="bc-small bc-muted">{hint?.[locale] || hint?.ru}</div>}
<div className="bc-small bc-muted">
{LOCALES.filter((l) => l !== 'ru' && !name[l]).length > 0 ? t('admin.templates.missing', { langs: LOCALES.filter((l) => l !== 'ru' && !name[l]).join(', ').toUpperCase() }) : ''}
</div>
</td>
<td>
{kind === 'STEP' && x.inTurnkey && <Badge style={{ background: 'var(--bc-pink)' }}>{t('admin.templates.turnkey')}</Badge>}
{!x.active && <Badge style={{ background: 'var(--bc-line)' }}>{t('admin.templates.hidden')}</Badge>}
</td>
<td style={{ width: 120 }}>
<Button size="sm" variant="ghost" onClick={() => setD({ id: x.id, kind, name: { ...empty(), ...name }, hint: { ...empty(), ...(hint ?? {}) }, active: x.active, inTurnkey: x.inTurnkey })}>
{t('admin.edit')}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</Card>
);
};
return (
<>
<h1 className="bc-h2">{t('admin.nav.templates')}</h1>
<p className="bc-muted" style={{ margin: 0 }}>
{t('admin.templates.sub')}
</p>
{section('STEP')}
{section('DOC')}
</>
);
}
function Editor({ d, setD, onSave }: { d: Draft; setD: (d: Draft | null) => void; onSave: () => void }) {
const { t } = useI18n();
return (
<div className="form-grid" style={{ background: 'var(--bc-bg)', padding: 12, borderRadius: 16 }}>
{LOCALES.map((l) => (
<Field key={l} label={`${t('admin.templates.name')} · ${l.toUpperCase()}`}>
<input className="bc-input" value={d.name[l] ?? ''} onChange={(e) => setD({ ...d, name: { ...d.name, [l]: e.target.value } })} />
</Field>
))}
{d.kind === 'DOC' &&
LOCALES.map((l) => (
<Field key={`h-${l}`} label={`${t('admin.templates.hintField')} · ${l.toUpperCase()}`}>
<input className="bc-input" value={d.hint[l] ?? ''} onChange={(e) => setD({ ...d, hint: { ...d.hint, [l]: e.target.value } })} />
</Field>
))}
<label className="bc-row">
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.templates.active')}
</label>
{d.kind === 'STEP' && (
<label className="bc-row">
<input type="checkbox" checked={d.inTurnkey} onChange={(e) => setD({ ...d, inTurnkey: e.target.checked })} /> {t('admin.templates.inTurnkey')}
</label>
)}
<div className="full bc-row">
<Button disabled={!d.name.ru?.trim()} onClick={onSave}>
{t('admin.save')}
</Button>
<Button variant="light" onClick={() => setD(null)}>
{t('common.cancel')}
</Button>
</div>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatAmd } from '@barev/i18n';
import { Badge, Button, Chip } from '@barev/ui';
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
const STATUSES = ['NEW', 'LINKED', 'ORDERED', 'CANCELLED'] as const;
type Status = (typeof STATUSES)[number];
const STATUS_TONE: Record<Status, string> = {
NEW: 'var(--bc-perks)',
LINKED: 'var(--bc-pink)',
ORDERED: 'var(--bc-home)',
CANCELLED: 'var(--bc-line)',
};
/** Заявки с сайта (barevclub.com/clean): появляются сразу после отправки формы, список обновляется сам */
export function WebRequests() {
const { t, dt, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [status, setStatus] = useState<Status | undefined>();
const list = useQuery({ ...trpc.admin.webRequests.list.queryOptions({ status }), refetchInterval: 15_000 });
const update = useMutation(
trpc.admin.webRequests.update.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.nav.webRequests')}</h1>
<div className="bc-chips">
<Chip pressed={!status} onClick={() => setStatus(undefined)}>
{t('admin.webRequests.all')}
</Chip>
{STATUSES.map((s) => (
<Chip key={s} pressed={status === s} onClick={() => setStatus(s)}>
{t(`admin.webRequests.status_${s}`)}
</Chip>
))}
</div>
</div>
{list.data?.length === 0 ? (
<p className="bc-muted">{t('admin.webRequests.empty')}</p>
) : (
<table className="grid">
<thead>
<tr>
<th>{t('admin.webRequests.number')}</th>
<th>{t('admin.webRequests.when')}</th>
<th>{t('admin.webRequests.what')}</th>
<th>{t('admin.webRequests.address')}</th>
<th>{t('admin.webRequests.contact')}</th>
<th>{t('admin.webRequests.price')}</th>
<th>{t('admin.webRequests.status')}</th>
<th>{t('admin.webRequests.note')}</th>
</tr>
</thead>
<tbody>
{list.data?.map((r) => {
const p = r.params as { cleaningType?: string; rooms?: number; bathrooms?: number; extras?: Record<string, number> };
const extras = Object.entries(p.extras ?? {});
return (
<tr key={r.id}>
<td>
<b>{r.number}</b>
<div className="bc-small bc-muted">{t('admin.webRequests.created', { date: dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) })}</div>
</td>
<td className="bc-small">{dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</td>
<td>
<b>{t(`params.cleaning.cleaningType.${p.cleaningType ?? 'regular'}`)}</b>
<div className="bc-small">
{t('params.cleaning.rooms.count', { count: p.rooms ?? 1 })}, {t('params.cleaning.bathrooms.count', { count: p.bathrooms ?? 1 })}
</div>
{extras.length > 0 && (
<div className="bc-small bc-muted">{extras.map(([code, qty]) => `${t(`extras.${code}.name`)}${qty > 1 ? ` × ${qty}` : ''}`).join(', ')}</div>
)}
</td>
<td className="bc-small">
{t(`districts.${r.district.code}`)}
<div>{r.addressText}</div>
{r.note && <div className="bc-muted">{r.note}</div>}
</td>
<td>
{r.name && <b>{r.name}</b>}
<div>
<a href={`tel:${r.phone}`}>{r.phone}</a>
</div>
{r.client && (
<div className="bc-small bc-muted">
{r.client.user.username ? `@${r.client.user.username}` : `${r.client.user.firstName ?? ''} ${r.client.user.lastName ?? ''}`}
</div>
)}
<div className="bc-small bc-muted">{r.locale.toUpperCase()}</div>
</td>
<td className="mono">{formatAmd(locale, r.totalPrice)}</td>
<td>
<Badge style={{ background: STATUS_TONE[r.status] }}>{t(`admin.webRequests.status_${r.status}`)}</Badge>
{r.order && (
<div className="bc-small" style={{ marginTop: 6 }}>
{t('admin.webRequests.order', { number: r.order.number })}
</div>
)}
<div style={{ marginTop: 8 }}>
{r.status === 'CANCELLED' ? (
<Button size="sm" variant="light" onClick={() => update.mutate({ id: r.id, status: 'REOPEN' })}>
{t('admin.webRequests.reopen')}
</Button>
) : (
r.status !== 'ORDERED' && (
<Button
size="sm"
variant="light"
onClick={async () => (await confirmDialog(t('admin.webRequests.cancelConfirm', { number: r.number }))) && update.mutate({ id: r.id, status: 'CANCELLED' })}
>
{t('admin.webRequests.cancel')}
</Button>
)
)}
</div>
</td>
<td style={{ minWidth: 200 }}>
<NoteEditor initial={r.adminNote ?? ''} onSave={(adminNote) => update.mutate({ id: r.id, adminNote })} />
</td>
</tr>
);
})}
</tbody>
</table>
)}
</>
);
}
function NoteEditor({ initial, onSave }: { initial: string; onSave: (v: string) => void }) {
const { t } = useI18n();
const [value, setValue] = useState(initial);
return (
<div className="bc-stack" style={{ gap: 6 }}>
<textarea className="bc-input" style={{ minHeight: 60 }} value={value} maxLength={1000} placeholder={t('admin.webRequests.notePlaceholder')} onChange={(e) => setValue(e.target.value)} />
{value !== initial && (
<Button size="sm" variant="outline" onClick={() => onSave(value)}>
{t('admin.webRequests.saveNote')}
</Button>
)}
</div>
);
}