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:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Barev Club · Admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,700;0,800;1,400&family=Onest:wght@400;500;600&family=Noto+Sans+Armenian:wght@400;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@barev/admin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173 --strictPort",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@barev/core": "workspace:*",
|
||||
"@barev/i18n": "workspace:*",
|
||||
"@barev/ui": "workspace:*",
|
||||
"@barev/web": "workspace:*",
|
||||
"@tanstack/react-query": "^5.104.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@barev/api": "workspace:*",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"@types/qrcode": "^1.5.5"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "types": ["node", "vite/client"] },
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: true,
|
||||
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
|
||||
},
|
||||
build: { target: 'es2022', sourcemap: true },
|
||||
});
|
||||
Reference in new issue
Block a user