diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dd68b95 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/node_modules +**/.next +**/dist +**/.turbo +**/*.tsbuildinfo +.git +.env +.env.* +docs/design +i18n-review +infra/prod/.env* diff --git a/.env.example b/.env.example index 44a2c2e..6f0401e 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,8 @@ SENTRY_DSN= # В production не используется. DEV_CLIENT_BOT_TOKEN=000000:dev-client-token DEV_PRO_BOT_TOKEN=000000:dev-pro-token + +# Страница /ru/apartments: квартиры AREV из CRM (crm.yandp.tech) и заявки на просмотр. +# Ключ — companies.settings.barevClub.key организации AREV в CRM. Пусто — страница показывает «список недоступен». +CRM_URL=https://crm.yandp.tech +CRM_BAREV_KEY= diff --git a/.gitignore b/.gitignore index b55b9d0..3919003 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ coverage !.env.example .DS_Store packages/db/src/generated + +# личные документы в корне (выписки, паспорта) — не коммитим +/*.pdf + +# рабочие копии веток Claude Code +.claude/worktrees diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..8028955 --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,16 @@ + + + + + + + Barev Club · Admin + + + + + +
+ + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..9827f69 --- /dev/null +++ b/apps/admin/package.json @@ -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" + } +} diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx new file mode 100644 index 0000000..e4cc80f --- /dev/null +++ b/apps/admin/src/App.tsx @@ -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 ( + + + + ); +} + +function Gate() { + const trpc = useTRPC(); + const me = useQuery({ ...trpc.admin.auth.me.queryOptions(), retry: false }); + if (me.isPending) + return ( +
+ +
+ ); + if (!me.data || !me.data.verified) return ; + return ; +} + +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 = { '/requests': openRequests.data, '/leads': openLeads.data, '/app-requests': openAppRequests.data, '/concierge': unreadChats.data }; + + let page: ReactNode; + if (path === '/leads') page = ; + else if (path === '/concierge') page = ; + else if (path === '/app-requests') page = ; + else if (path === '/partners') page = ; + else if (path === '/templates') page = ; + else if (path === '/telegram') page = ; + else if (path === '/requests') page = ; + else if (path === '/clients') page = ; + else if (path === '/providers') page = ; + else if (path === '/presets') page = ; + else if (path === '/companies') page = ; + else if (path === '/services') page = ; + else if (path === '/agencies') page = ; + else if (path === '/settings' && isOwner) page = ; + else page = ; + + return ( +
+ +
{page}
+
+ ); +} diff --git a/apps/admin/src/admin.css b/apps/admin/src/admin.css new file mode 100644 index 0000000..0b51c80 --- /dev/null +++ b/apps/admin/src/admin.css @@ -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); +} diff --git a/apps/admin/src/components/OrderDrawer.tsx b/apps/admin/src/components/OrderDrawer.tsx new file mode 100644 index 0000000..4e8fdba --- /dev/null +++ b/apps/admin/src/components/OrderDrawer.tsx @@ -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 ( +
+ +
+ ); +} + +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 ; + const [best, ...rest] = cands.data.ranked; + return ( + +

{t('admin.order.assignTitle')}

+ {best ? ( + + ) : ( +

{t('admin.order.noCandidates')}

+ )} + {best && } + {rest.length > 0 && ( + + )} + {showAll && + rest.map((c) => ( +
+
+
+ {c.name} + {c.kind === 'company' ? ` · ${t('admin.order.company')}` : ''} +
+
+ {c.languages.map((l) => l.toUpperCase()).join(' ')} · {t('admin.order.load', { count: c.load })} + {c.rating ? ` · ★ ${c.rating}` : ''} +
+ +
+ +
+ ))} + {cands.data.busy.length > 0 && ( +
+ {t('admin.order.busy')}: {cands.data.busy.map((b) => b.name).join(', ')} +
+ )} +
+ ); +} + +function Reasons({ reasons }: { reasons: string[] }) { + const { t } = useI18n(); + if (!reasons.length) return null; + return ( +
+ {reasons.map((r) => ( + + {t(`admin.reasons.${r}`)} + + ))} +
+ ); +} + +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 ( + + ); +} diff --git a/apps/admin/src/components/form.tsx b/apps/admin/src/components/form.tsx new file mode 100644 index 0000000..5be2163 --- /dev/null +++ b/apps/admin/src/components/form.tsx @@ -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({ value, options, onChange, label }: { value: T[]; options: { value: T; label: ReactNode }[]; onChange: (v: T[]) => void; label?: string }) { + return ( +
+ {options.map((o) => { + const on = value.includes(o.value); + return ( + onChange(on ? value.filter((v) => v !== o.value) : [...value, o.value])} style={{ minHeight: 36, padding: '4px 12px' }}> + {o.label} + + ); + })} +
+ ); +} + +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 ( +
+ {[0, 1, 2, 3, 4, 5, 6].map((wd) => { + const row = value.find((r) => r.weekday === wd); + const update = (patch: Partial) => onChange(value.map((r) => (r.weekday === wd ? { ...r, ...patch } : r))); + return ( +
+ {t(`admin.weekdays.${wd}`)} + update({ startMinute: fromHm(e.target.value) })} aria-label={t('admin.schedule.from')} /> + update({ endMinute: fromHm(e.target.value) })} aria-label={t('admin.schedule.to')} /> + onChange(e.target.checked ? [...value, { weekday: wd, startMinute: 540, endMinute: 1200 }] : value.filter((r) => r.weekday !== wd))} + style={{ width: 22, height: 22 }} + /> +
+ ); + })} +
+ ); +} + +/** Локальная копия значения, сбрасывается при смене исходного */ +export function useDraft(source: T | undefined) { + const [draft, setDraft] = useState(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 ( + + + {text} + + + + ); +} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx new file mode 100644 index 0000000..261a793 --- /dev/null +++ b/apps/admin/src/main.tsx @@ -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( + + + + + + + , +); diff --git a/apps/admin/src/pages/Agencies.tsx b/apps/admin/src/pages/Agencies.tsx new file mode 100644 index 0000000..b3a6e0a --- /dev/null +++ b/apps/admin/src/pages/Agencies.tsx @@ -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(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 ( + <> +
+

{t('admin.nav.agencies')}

+ {isOwner && ( + + )} +
+

+ {t('admin.agencies.hint', { months: settings.data?.['agency.attributionMonths'] ?? 12 })} +

+ {d && ( + +
+ + setD({ ...d, code: e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '') })} maxLength={32} /> + + + setD({ ...d, name: e.target.value })} /> + + + setD({ ...d, commissionPct: Number(e.target.value) })} /> + + + setD({ ...d, contact: e.target.value })} /> + + +
+
+ + +
+
+ )} + + + + + + + + + + + + {list.data?.map((a) => ( + isOwner && setD({ id: a.id, code: a.code, name: a.name, commissionPct: a.commissionPct, contact: a.contact ?? '', active: a.active })}> + + + + + + + ))} + +
{t('admin.agencies.name')}{t('admin.agencies.code')}{t('admin.agencies.commission')}{t('admin.agencies.leads')}{t('admin.agencies.links')}
+ {a.name} + {a.contact &&
{a.contact}
} +
{a.code}{a.commissionPct}%{a.leads} e.stopPropagation()} className="bc-stack" style={{ gap: 4 }}> + {config.data && ( + <> + {t('admin.agencies.bot')} + + {t('admin.agencies.landing')} + + + )} +
+ + ); +} diff --git a/apps/admin/src/pages/Clients.tsx b/apps/admin/src/pages/Clients.tsx new file mode 100644 index 0000000..d0c9b98 --- /dev/null +++ b/apps/admin/src/pages/Clients.tsx @@ -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 ( + <> +
+

{t('admin.nav.clients')}

+ setSearch(e.target.value)} style={{ width: 280 }} /> +
+ + + + + + + + + + {isOwner && + + + {list.data?.map((c) => ( + + + + + + + + {isOwner && ( + + )} + + ))} + +
{t('admin.clients.name')}{t('admin.clients.language')}{t('admin.clients.source')}{t('admin.clients.membership')}{t('admin.clients.orders')}{t('admin.clients.since')}} +
+ + {c.user.firstName} {c.user.lastName ?? ''} + + {c.user.username &&
@{c.user.username}
} + + {t('admin.concierge.write')} + + {!c.consented && {t('admin.clients.noConsent')}} +
{c.user.locale.toUpperCase()} + {c.agency ? `${c.agency.name} (${c.agency.code})` : t(`admin.clients.source_${c.source}`)} + {c.utm &&
{Object.entries(c.utm as Record).map(([k, v]) => `${k.replace('utm_', '')}=${v}`).join(' ')}
} +
+ {c.barevPlusUntil ? ( + + {c.plan === 'ELITE' ? 'Elite' : 'Barev+'} · {dt(c.barevPlusUntil, { day: 'numeric', month: 'short' })} + + ) : ( + 'Barev' + )} + {c.segment &&
{t(`admin.clients.segment_${c.segment}`)}
} +
{c.orders}{dt(c.createdAt, { dateStyle: 'medium' })} +
+ + +
+
+ + ); +} diff --git a/apps/admin/src/pages/Companies.tsx b/apps/admin/src/pages/Companies.tsx new file mode 100644 index 0000000..ec00a22 --- /dev/null +++ b/apps/admin/src/pages/Companies.tsx @@ -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(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 ( + <> +
+

{t('admin.nav.companies')}

+ +
+ {d && ( + +
+ + setD({ ...d, name: e.target.value })} /> + + + setD({ ...d, contactPhone: e.target.value })} /> + +
+ + ({ value: r, label: t(`roles.${r}`) }))} onChange={(roles) => setD({ ...d, roles })} /> + +
+ + + + + setD({ ...d, rateValue: Number(e.target.value) })} /> + + +
+
+ + +
+
+ )} + {list.data?.map((c) => ( + +
+
+

{c.name}

+
+ {c.roles.map((r) => t(`roles.${r}`)).join(', ')} · {c.rateType === 'PERCENT' ? `${c.rateValue}%` : `${c.rateValue} ֏`} +
+
+ +
+
+ {t('admin.companies.employees')}: {c.employees.map((e) => e.name).join(', ') || '—'} +
+
+ {t('admin.companies.dispatchers')}:{' '} + {c.dispatchers.length + ? c.dispatchers.map((x) => ( + + {x.user.firstName ?? x.user.username} + + + )) + : '—'} +
+ {config.data && ( +
+ {t('admin.companies.dispatcherInvite')}: +
+ )} +
+ ))} + + ); +} diff --git a/apps/admin/src/pages/Concierge.tsx b/apps/admin/src/pages/Concierge.tsx new file mode 100644 index 0000000..2c94923 --- /dev/null +++ b/apps/admin/src/pages/Concierge.tsx @@ -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 ( + <> +
+

{t('admin.nav.concierge')}

+
+ {(['all', 'clients', 'providers'] as const).map((x) => ( + setTab(x)}> + {t(`admin.concierge.tab_${x}`)} + + ))} +
+
+ {!selected ? ( +

{t('admin.concierge.empty')}

+ ) : ( +
+
    + {items.map((c) => ( +
  • + +
  • + ))} +
+ +
+ )} + + ); +} + +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(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 ( +
+
+ {p?.name} + {p?.username && ( + + @{p.username} + + )} + {p && {t('admin.concierge.replyLang', { lang: p.locale.toUpperCase() })}} +
+
+ {data.data?.messages.length === 0 &&

{t('admin.concierge.startHint')}

} + {data.data?.messages.map((m) => ( +
+
{m.text}
+ {dt(m.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} +
+ ))} +
+
+ {p && !p.reachable ? ( +

+ {t('errors.PROVIDER_NOT_LINKED')} +

+ ) : ( +
{ + e.preventDefault(); + send(); + }} + > +