Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
148 lines
6.4 KiB
TypeScript
148 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|